Add comprehensive test suite: 219 new tests across backend and frontend
Backend (103 tests): - Unit tests for listing_service, export_service, district_service - Regression tests for API response contracts and query parameter validation - Integration tests for API workflows, Redis listing cache, listing processor pipeline, and repository advanced queries - E2E tests for streaming with filters, batching, caching, and task management Frontend (116 tests): - Service tests for apiClient, streamingService, taskService, listingService, healthService - Hook tests for useTaskProgress (WebSocket + polling) - Component tests for PropertyCard, FilterPanel, Header, ListView, TaskProgressDrawer, TaskIndicator, StreamingProgressBar, HealthIndicator - E2E tests for filter-stream-display flow Infrastructure: - Add pytest-xdist and test markers (regression, integration, e2e) - Add conftest fixtures: fake_redis, rent_listing_factory, seeded_repository - Add vitest + testing-library + MSW for frontend testing
This commit is contained in:
parent
a3ac9cc060
commit
8d22c97320
36 changed files with 5447 additions and 19 deletions
|
|
@ -1,7 +1,8 @@
|
|||
"""Shared pytest fixtures for the test suite."""
|
||||
from datetime import datetime
|
||||
from typing import AsyncGenerator, Generator
|
||||
from typing import Any, AsyncGenerator, Callable, Generator
|
||||
import pytest
|
||||
import fakeredis
|
||||
from sqlalchemy import Engine
|
||||
from sqlmodel import SQLModel, Session, create_engine
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
|
@ -184,3 +185,59 @@ async def async_client(
|
|||
|
||||
# Clean up dependency overrides
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_redis() -> Generator[fakeredis.FakeRedis, None, None]:
|
||||
"""Create a fakeredis client, flushed after each test."""
|
||||
client = fakeredis.FakeRedis(decode_responses=True)
|
||||
yield client
|
||||
client.flushall()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rent_listing_factory() -> Callable[..., RentListing]:
|
||||
"""Factory function that creates RentListing with overridable defaults."""
|
||||
_counter = 0
|
||||
|
||||
def _create(**overrides: Any) -> RentListing:
|
||||
nonlocal _counter
|
||||
_counter += 1
|
||||
defaults: dict[str, Any] = dict(
|
||||
id=_counter,
|
||||
price=2000.0,
|
||||
number_of_bedrooms=2,
|
||||
square_meters=55.0,
|
||||
agency="Test Agency",
|
||||
council_tax_band="C",
|
||||
longitude=-0.1276,
|
||||
latitude=51.5074,
|
||||
price_history_json="[]",
|
||||
listing_site=ListingSite.RIGHTMOVE,
|
||||
last_seen=datetime.now(),
|
||||
photo_thumbnail="https://example.com/photo.jpg",
|
||||
floorplan_image_paths=[],
|
||||
additional_info={"property": {"visible": True}},
|
||||
routing_info_json=None,
|
||||
furnish_type=FurnishType.FURNISHED,
|
||||
available_from=datetime.now(),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return RentListing(**defaults)
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def seeded_repository(
|
||||
in_memory_engine: Engine,
|
||||
rent_listing_factory: Callable[..., RentListing],
|
||||
) -> ListingRepository:
|
||||
"""Repository with 10 pre-seeded listings (varied price/bedrooms/sqm)."""
|
||||
repo = ListingRepository(engine=in_memory_engine)
|
||||
listings = [
|
||||
rent_listing_factory(id=100 + i, price=1000 + i * 300, number_of_bedrooms=(i % 4) + 1, square_meters=30 + i * 10)
|
||||
for i in range(10)
|
||||
]
|
||||
await repo.upsert_listings(listings)
|
||||
return repo
|
||||
|
|
|
|||
0
tests/e2e/__init__.py
Normal file
0
tests/e2e/__init__.py
Normal file
203
tests/e2e/test_full_workflows.py
Normal file
203
tests/e2e/test_full_workflows.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""End-to-end tests for full API workflows."""
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import Engine
|
||||
|
||||
from repositories.listing_repository import ListingRepository
|
||||
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_db_engine(in_memory_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import database
|
||||
import api.app
|
||||
import api.rate_limiter
|
||||
monkeypatch.setattr(database, "engine", in_memory_engine)
|
||||
monkeypatch.setattr(api.app, "engine", in_memory_engine)
|
||||
# Disable rate limiting for E2E tests
|
||||
monkeypatch.setattr(api.rate_limiter, "_match_endpoint", lambda path, config: None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_redis_client(fake_redis, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.listing_cache._get_redis_client", lambda: fake_redis)
|
||||
|
||||
|
||||
def _parse_ndjson(text: str) -> list[dict]:
|
||||
return [json.loads(line) for line in text.strip().split("\n") if line.strip()]
|
||||
|
||||
|
||||
# ---------- Streaming with filters ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_and_stream_with_filter(
|
||||
async_client: AsyncClient,
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listings = [
|
||||
rent_listing_factory(id=i, price=1000 + i * 300, square_meters=40.0)
|
||||
for i in range(1, 21)
|
||||
]
|
||||
await listing_repository.upsert_listings(listings)
|
||||
|
||||
resp = await async_client.get(
|
||||
"/api/listing_geojson/stream?listing_type=RENT&max_price=3000"
|
||||
)
|
||||
lines = _parse_ndjson(resp.text)
|
||||
batches = [l for l in lines if l["type"] == "batch"]
|
||||
all_features = [f for b in batches for f in b["features"]]
|
||||
complete = lines[-1]
|
||||
|
||||
for feat in all_features:
|
||||
assert feat["properties"]["total_price"] <= 3000
|
||||
assert complete["type"] == "complete"
|
||||
assert complete["total"] == len(all_features)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_large_batch_streaming(
|
||||
async_client: AsyncClient,
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listings = [
|
||||
rent_listing_factory(id=i, square_meters=40.0)
|
||||
for i in range(1, 201)
|
||||
]
|
||||
await listing_repository.upsert_listings(listings)
|
||||
|
||||
resp = await async_client.get(
|
||||
"/api/listing_geojson/stream?listing_type=RENT&batch_size=50"
|
||||
)
|
||||
lines = _parse_ndjson(resp.text)
|
||||
batches = [l for l in lines if l["type"] == "batch"]
|
||||
complete = lines[-1]
|
||||
|
||||
assert len(batches) == 4
|
||||
assert complete["total"] == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_result_set(async_client: AsyncClient) -> None:
|
||||
resp = await async_client.get(
|
||||
"/api/listing_geojson/stream?listing_type=RENT"
|
||||
)
|
||||
lines = _parse_ndjson(resp.text)
|
||||
|
||||
assert lines[0]["type"] == "metadata"
|
||||
complete = lines[-1]
|
||||
assert complete["type"] == "complete"
|
||||
assert complete["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_creates_task(
|
||||
async_client: AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from services.listing_service import RefreshResult
|
||||
|
||||
async def fake_refresh(*args, **kwargs):
|
||||
return RefreshResult(task_id="e2e-task-1", new_listings_count=0, message="started")
|
||||
|
||||
monkeypatch.setattr("services.listing_service.refresh_listings", fake_refresh)
|
||||
monkeypatch.setattr("services.task_service.add_task_for_user", lambda email, tid: None)
|
||||
monkeypatch.setattr("notifications.send_notification", AsyncMock(return_value=None))
|
||||
|
||||
resp = await async_client.post("/api/refresh_listings?listing_type=RENT")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["task_id"] == "e2e-task-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_populated_on_first_stream(
|
||||
async_client: AsyncClient,
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 6)]
|
||||
await listing_repository.upsert_listings(listings)
|
||||
|
||||
# First stream — cache miss, populated from DB
|
||||
resp1 = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
|
||||
lines1 = _parse_ndjson(resp1.text)
|
||||
assert lines1[0]["cached"] is False
|
||||
|
||||
# Second stream — should hit cache
|
||||
resp2 = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
|
||||
lines2 = _parse_ndjson(resp2.text)
|
||||
assert lines2[0]["cached"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_filter_price(
|
||||
async_client: AsyncClient,
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listings = [
|
||||
rent_listing_factory(id=i, price=500 * i, square_meters=40.0)
|
||||
for i in range(1, 11)
|
||||
]
|
||||
await listing_repository.upsert_listings(listings)
|
||||
|
||||
resp = await async_client.get(
|
||||
"/api/listing_geojson/stream?listing_type=RENT&min_price=1500&max_price=3000"
|
||||
)
|
||||
lines = _parse_ndjson(resp.text)
|
||||
batches = [l for l in lines if l["type"] == "batch"]
|
||||
all_features = [f for b in batches for f in b["features"]]
|
||||
|
||||
for feat in all_features:
|
||||
assert 1500 <= feat["properties"]["total_price"] <= 3000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_filter_bedrooms(
|
||||
async_client: AsyncClient,
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listings = [
|
||||
rent_listing_factory(id=i, number_of_bedrooms=(i % 4) + 1, square_meters=40.0)
|
||||
for i in range(1, 21)
|
||||
]
|
||||
await listing_repository.upsert_listings(listings)
|
||||
|
||||
resp = await async_client.get(
|
||||
"/api/listing_geojson/stream?listing_type=RENT&min_bedrooms=2&max_bedrooms=3"
|
||||
)
|
||||
lines = _parse_ndjson(resp.text)
|
||||
batches = [l for l in lines if l["type"] == "batch"]
|
||||
all_features = [f for b in batches for f in b["features"]]
|
||||
|
||||
for feat in all_features:
|
||||
assert 2 <= feat["properties"]["rooms"] <= 3
|
||||
assert len(all_features) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_total_matches_actual(
|
||||
async_client: AsyncClient,
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 16)]
|
||||
await listing_repository.upsert_listings(listings)
|
||||
|
||||
resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
|
||||
lines = _parse_ndjson(resp.text)
|
||||
batches = [l for l in lines if l["type"] == "batch"]
|
||||
total_features = sum(len(b["features"]) for b in batches)
|
||||
complete = lines[-1]
|
||||
|
||||
assert complete["type"] == "complete"
|
||||
assert complete["total"] == total_features
|
||||
assert complete["total"] == 15
|
||||
320
tests/integration/test_api_workflow.py
Normal file
320
tests/integration/test_api_workflow.py
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
"""Integration tests for API workflow endpoints."""
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import Engine
|
||||
|
||||
from repositories.listing_repository import ListingRepository
|
||||
from services.task_service import TaskStatus
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_db_engine(in_memory_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Patch the database engine and disable rate limiting for tests."""
|
||||
import database
|
||||
import api.app
|
||||
import api.rate_limiter
|
||||
monkeypatch.setattr(database, "engine", in_memory_engine)
|
||||
monkeypatch.setattr(api.app, "engine", in_memory_engine)
|
||||
# Disable rate limiting by making _match_endpoint always return None
|
||||
monkeypatch.setattr(api.rate_limiter, "_match_endpoint", lambda path, config: None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_redis_for_streaming(fake_redis, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Patch Redis client used by listing cache so streaming doesn't hit real Redis."""
|
||||
monkeypatch.setattr("services.listing_cache._get_redis_client", lambda: fake_redis)
|
||||
|
||||
|
||||
# ---------- Listing query tests ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_listing_returns_inserted_data(
|
||||
async_client: AsyncClient,
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listings = [rent_listing_factory(id=i) for i in range(1, 4)]
|
||||
await listing_repository.upsert_listings(listings)
|
||||
|
||||
resp = await async_client.get("/api/listing?limit=10")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["listings"]) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_listing_respects_limit(
|
||||
async_client: AsyncClient,
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listings = [rent_listing_factory(id=i) for i in range(1, 6)]
|
||||
await listing_repository.upsert_listings(listings)
|
||||
|
||||
resp = await async_client.get("/api/listing?limit=2")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["listings"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_geojson_returns_feature_collection(
|
||||
async_client: AsyncClient,
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listings = [rent_listing_factory(id=i, square_meters=50.0) for i in range(1, 4)]
|
||||
await listing_repository.upsert_listings(listings)
|
||||
|
||||
resp = await async_client.get("/api/listing_geojson?listing_type=RENT")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "FeatureCollection"
|
||||
assert len(data["features"]) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_geojson_features_have_properties(
|
||||
async_client: AsyncClient,
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listing = rent_listing_factory(id=1, price=2500, number_of_bedrooms=2, square_meters=60.0)
|
||||
await listing_repository.upsert_listings([listing])
|
||||
|
||||
resp = await async_client.get("/api/listing_geojson?listing_type=RENT")
|
||||
assert resp.status_code == 200
|
||||
features = resp.json()["features"]
|
||||
assert len(features) == 1
|
||||
|
||||
feat = features[0]
|
||||
assert feat["geometry"]["type"] == "Point"
|
||||
props = feat["properties"]
|
||||
for key in ("url", "total_price", "rooms", "qm", "qmprice", "agency", "last_seen"):
|
||||
assert key in props, f"Missing property: {key}"
|
||||
|
||||
|
||||
# ---------- Streaming tests ----------
|
||||
|
||||
|
||||
def _parse_ndjson(text: str) -> list[dict]:
|
||||
return [json.loads(line) for line in text.strip().split("\n") if line.strip()]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_metadata_batch_complete(
|
||||
async_client: AsyncClient,
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 4)]
|
||||
await listing_repository.upsert_listings(listings)
|
||||
|
||||
resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
|
||||
assert resp.status_code == 200
|
||||
lines = _parse_ndjson(resp.text)
|
||||
|
||||
assert lines[0]["type"] == "metadata"
|
||||
batches = [l for l in lines if l["type"] == "batch"]
|
||||
assert len(batches) >= 1
|
||||
complete = lines[-1]
|
||||
assert complete["type"] == "complete"
|
||||
assert complete["total"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_respects_limit(
|
||||
async_client: AsyncClient,
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 11)]
|
||||
await listing_repository.upsert_listings(listings)
|
||||
|
||||
resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT&limit=3")
|
||||
lines = _parse_ndjson(resp.text)
|
||||
complete = lines[-1]
|
||||
assert complete["type"] == "complete"
|
||||
assert complete["total"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_empty_db(async_client: AsyncClient) -> None:
|
||||
resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT")
|
||||
lines = _parse_ndjson(resp.text)
|
||||
|
||||
assert lines[0]["type"] == "metadata"
|
||||
complete = lines[-1]
|
||||
assert complete["type"] == "complete"
|
||||
assert complete["total"] == 0
|
||||
|
||||
|
||||
# ---------- Task management tests ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_returns_task_id(
|
||||
async_client: AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from services.listing_service import RefreshResult
|
||||
async def fake_refresh(*args, **kwargs):
|
||||
return RefreshResult(task_id="test-123", new_listings_count=0, message="Task started")
|
||||
|
||||
import services.listing_service
|
||||
monkeypatch.setattr(services.listing_service, "refresh_listings", fake_refresh)
|
||||
monkeypatch.setattr("services.task_service.add_task_for_user", lambda email, tid: None)
|
||||
monkeypatch.setattr("notifications.send_notification", AsyncMock(return_value=None))
|
||||
|
||||
resp = await async_client.post("/api/refresh_listings?listing_type=RENT")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["task_id"] == "test-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_tracked_for_user(
|
||||
async_client: AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from services.listing_service import RefreshResult
|
||||
async def fake_refresh(*args, **kwargs):
|
||||
return RefreshResult(task_id="task-abc", new_listings_count=0, message="ok")
|
||||
|
||||
import services.listing_service
|
||||
monkeypatch.setattr(services.listing_service, "refresh_listings", fake_refresh)
|
||||
monkeypatch.setattr("notifications.send_notification", AsyncMock(return_value=None))
|
||||
|
||||
calls: list[tuple[str, str]] = []
|
||||
import services.task_service
|
||||
monkeypatch.setattr(
|
||||
services.task_service,
|
||||
"add_task_for_user",
|
||||
lambda email, tid: calls.append((email, tid)),
|
||||
)
|
||||
|
||||
await async_client.post("/api/refresh_listings?listing_type=RENT")
|
||||
assert len(calls) == 1
|
||||
assert calls[0] == ("test@example.com", "task-abc")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_status_works(
|
||||
async_client: AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: ["test-123"])
|
||||
monkeypatch.setattr(
|
||||
"services.task_service.get_task_status",
|
||||
lambda task_id: TaskStatus(
|
||||
task_id="test-123",
|
||||
status="SUCCESS",
|
||||
result=None,
|
||||
progress=1.0,
|
||||
processed=10,
|
||||
total=10,
|
||||
message="Done",
|
||||
error=None,
|
||||
traceback=None,
|
||||
),
|
||||
)
|
||||
|
||||
resp = await async_client.get("/api/task_status?task_id=test-123")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["task_id"] == "test-123"
|
||||
assert data["status"] == "SUCCESS"
|
||||
assert data["progress"] == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_not_found_returns_404(
|
||||
async_client: AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: [])
|
||||
|
||||
resp = await async_client.get("/api/task_status?task_id=unknown")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_task(
|
||||
async_client: AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: ["test-123"])
|
||||
monkeypatch.setattr("services.task_service.cancel_task", lambda task_id, user_email=None: True)
|
||||
|
||||
resp = await async_client.post("/api/cancel_task?task_id=test-123")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_all_tasks(
|
||||
async_client: AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("services.task_service.clear_all_tasks", lambda email: 3)
|
||||
|
||||
resp = await async_client.post("/api/clear_all_tasks")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["count"] == 3
|
||||
assert data["success"] is True
|
||||
|
||||
|
||||
# ---------- Additional edge cases ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_listing_empty_db_returns_empty(async_client: AsyncClient) -> None:
|
||||
resp = await async_client.get("/api/listing?limit=10")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["listings"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_geojson_empty_db_returns_empty_collection(async_client: AsyncClient) -> None:
|
||||
resp = await async_client.get("/api/listing_geojson?listing_type=RENT")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "FeatureCollection"
|
||||
assert len(data["features"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_task_not_owned(
|
||||
async_client: AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: [])
|
||||
|
||||
resp = await async_client.post("/api/cancel_task?task_id=not-mine")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tasks_for_user(
|
||||
async_client: AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: ["a", "b", "c"])
|
||||
|
||||
resp = await async_client.get("/api/tasks_for_user")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == ["a", "b", "c"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_endpoint(async_client: AsyncClient) -> None:
|
||||
resp = await async_client.get("/api/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "OK"
|
||||
132
tests/integration/test_listing_cache.py
Normal file
132
tests/integration/test_listing_cache.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""Integration tests for Redis-based listing cache."""
|
||||
import pytest
|
||||
|
||||
from models.listing import ListingType, QueryParameters
|
||||
from services.listing_cache import (
|
||||
begin_cache_population,
|
||||
cache_features_batch,
|
||||
cache_features_batch_staged,
|
||||
delete_staging_key,
|
||||
finalize_cache_population,
|
||||
get_cached_count,
|
||||
get_cached_features,
|
||||
invalidate_cache,
|
||||
make_cache_key,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_redis(fake_redis, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Route all cache operations through fakeredis."""
|
||||
monkeypatch.setattr("services.listing_cache._get_redis_client", lambda: fake_redis)
|
||||
|
||||
|
||||
def _make_qp(**kwargs) -> QueryParameters:
|
||||
return QueryParameters(listing_type=ListingType.RENT, **kwargs)
|
||||
|
||||
|
||||
def _sample_features(n: int) -> list[dict]:
|
||||
return [{"type": "Feature", "id": i, "properties": {"price": 1000 + i}} for i in range(n)]
|
||||
|
||||
|
||||
# ---------- Basic read/write ----------
|
||||
|
||||
|
||||
def test_cache_miss_returns_none() -> None:
|
||||
qp = _make_qp()
|
||||
assert get_cached_count(qp) is None
|
||||
|
||||
|
||||
def test_cache_write_then_read() -> None:
|
||||
qp = _make_qp()
|
||||
features = _sample_features(5)
|
||||
cache_features_batch(qp, features)
|
||||
|
||||
count = get_cached_count(qp)
|
||||
assert count == 5
|
||||
|
||||
|
||||
def test_batch_retrieval() -> None:
|
||||
qp = _make_qp()
|
||||
cache_features_batch(qp, _sample_features(10))
|
||||
|
||||
batches = list(get_cached_features(qp, batch_size=3))
|
||||
sizes = [len(b) for b in batches]
|
||||
assert sizes == [3, 3, 3, 1]
|
||||
|
||||
|
||||
# ---------- Cache key behaviour ----------
|
||||
|
||||
|
||||
def test_cache_key_deterministic() -> None:
|
||||
qp1 = _make_qp()
|
||||
qp2 = _make_qp()
|
||||
assert make_cache_key(qp1) == make_cache_key(qp2)
|
||||
|
||||
|
||||
def test_cache_key_different_for_different_params() -> None:
|
||||
rent = _make_qp()
|
||||
buy = QueryParameters(listing_type=ListingType.BUY)
|
||||
assert make_cache_key(rent) != make_cache_key(buy)
|
||||
|
||||
|
||||
# ---------- Staged population ----------
|
||||
|
||||
|
||||
def test_staged_population_begin() -> None:
|
||||
qp = _make_qp()
|
||||
staging_key = begin_cache_population(qp)
|
||||
assert isinstance(staging_key, str)
|
||||
assert "staging" in staging_key
|
||||
|
||||
|
||||
def test_staged_write_then_finalize() -> None:
|
||||
qp = _make_qp()
|
||||
staging_key = begin_cache_population(qp)
|
||||
cache_features_batch_staged(staging_key, _sample_features(4))
|
||||
finalize_cache_population(staging_key, qp)
|
||||
|
||||
assert get_cached_count(qp) == 4
|
||||
|
||||
|
||||
def test_staging_key_deleted_on_cleanup(fake_redis) -> None:
|
||||
qp = _make_qp()
|
||||
staging_key = begin_cache_population(qp)
|
||||
cache_features_batch_staged(staging_key, _sample_features(2))
|
||||
delete_staging_key(staging_key)
|
||||
|
||||
assert fake_redis.exists(staging_key) == 0
|
||||
|
||||
|
||||
# ---------- Invalidation ----------
|
||||
|
||||
|
||||
def test_invalidation() -> None:
|
||||
qp = _make_qp()
|
||||
cache_features_batch(qp, _sample_features(5))
|
||||
assert get_cached_count(qp) == 5
|
||||
|
||||
invalidate_cache()
|
||||
assert get_cached_count(qp) is None
|
||||
|
||||
|
||||
# ---------- Edge cases ----------
|
||||
|
||||
|
||||
def test_empty_features_batch_noop() -> None:
|
||||
qp = _make_qp()
|
||||
cache_features_batch(qp, [])
|
||||
assert get_cached_count(qp) is None
|
||||
|
||||
|
||||
def test_multiple_batches_accumulate() -> None:
|
||||
qp = _make_qp()
|
||||
cache_features_batch(qp, _sample_features(3))
|
||||
cache_features_batch(qp, _sample_features(4))
|
||||
assert get_cached_count(qp) == 7
|
||||
|
||||
|
||||
def test_get_cached_features_empty() -> None:
|
||||
qp = _make_qp()
|
||||
batches = list(get_cached_features(qp))
|
||||
assert batches == []
|
||||
186
tests/integration/test_listing_processor.py
Normal file
186
tests/integration/test_listing_processor.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"""Integration tests for ListingProcessor and processing steps."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
|
||||
from listing_processor import (
|
||||
DetectFloorplanStep,
|
||||
FetchImagesStep,
|
||||
FetchListingDetailsStep,
|
||||
ListingProcessor,
|
||||
)
|
||||
from models.listing import ListingType
|
||||
from repositories.listing_repository import ListingRepository
|
||||
|
||||
|
||||
# ---------- Processor structure tests ----------
|
||||
|
||||
|
||||
def test_processor_has_three_steps(listing_repository: ListingRepository) -> None:
|
||||
processor = ListingProcessor(listing_repository)
|
||||
assert len(processor.process_steps) == 3
|
||||
|
||||
|
||||
def test_step_order(listing_repository: ListingRepository) -> None:
|
||||
processor = ListingProcessor(listing_repository)
|
||||
types = [type(s) for s in processor.process_steps]
|
||||
assert types == [FetchListingDetailsStep, FetchImagesStep, DetectFloorplanStep]
|
||||
|
||||
|
||||
# ---------- Processing flow ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_calls_steps_in_order(
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
# Seed a listing so mark_seen doesn't fail
|
||||
listing = rent_listing_factory(id=42)
|
||||
await listing_repository.upsert_listings([listing])
|
||||
|
||||
processor = ListingProcessor(listing_repository)
|
||||
|
||||
call_order: list[str] = []
|
||||
for step in processor.process_steps:
|
||||
name = type(step).__name__
|
||||
step.needs_processing = AsyncMock(return_value=True)
|
||||
step.process = AsyncMock(
|
||||
side_effect=lambda lid, n=name: call_order.append(n) or listing
|
||||
)
|
||||
|
||||
result = await processor.process_listing(42)
|
||||
assert result is not None
|
||||
assert call_order == [
|
||||
"FetchListingDetailsStep",
|
||||
"FetchImagesStep",
|
||||
"DetectFloorplanStep",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_failure_stops_pipeline(
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listing = rent_listing_factory(id=42)
|
||||
await listing_repository.upsert_listings([listing])
|
||||
|
||||
processor = ListingProcessor(listing_repository)
|
||||
|
||||
processor.process_steps[0].needs_processing = AsyncMock(return_value=True)
|
||||
processor.process_steps[0].process = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
processor.process_steps[1].needs_processing = AsyncMock(return_value=True)
|
||||
processor.process_steps[1].process = AsyncMock()
|
||||
processor.process_steps[2].needs_processing = AsyncMock(return_value=True)
|
||||
processor.process_steps[2].process = AsyncMock()
|
||||
|
||||
result = await processor.process_listing(42)
|
||||
assert result is None
|
||||
# Second and third steps should not have been called
|
||||
processor.process_steps[1].process.assert_not_called()
|
||||
processor.process_steps[2].process.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_fired_per_step(
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listing = rent_listing_factory(id=42)
|
||||
await listing_repository.upsert_listings([listing])
|
||||
|
||||
processor = ListingProcessor(listing_repository)
|
||||
|
||||
for step in processor.process_steps:
|
||||
step.needs_processing = AsyncMock(return_value=True)
|
||||
step.process = AsyncMock(return_value=listing)
|
||||
|
||||
callback_args: list[str] = []
|
||||
await processor.process_listing(42, on_step_complete=lambda name: callback_args.append(name))
|
||||
|
||||
assert callback_args == ["details", "images", "ocr"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_skipped_when_not_needed(
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
listing = rent_listing_factory(id=42)
|
||||
await listing_repository.upsert_listings([listing])
|
||||
|
||||
processor = ListingProcessor(listing_repository)
|
||||
|
||||
for step in processor.process_steps:
|
||||
step.needs_processing = AsyncMock(return_value=False)
|
||||
step.process = AsyncMock()
|
||||
|
||||
await processor.process_listing(42)
|
||||
|
||||
for step in processor.process_steps:
|
||||
step.process.assert_not_called()
|
||||
|
||||
|
||||
# ---------- Individual step tests ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_details_creates_listing(
|
||||
listing_repository: ListingRepository,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sample_detail = {
|
||||
"property": {
|
||||
"price": 2000,
|
||||
"bedrooms": 2,
|
||||
"branch": {"brandName": "Test Agency"},
|
||||
"councilTaxInfo": {"content": [{"value": "C"}]},
|
||||
"longitude": -0.1,
|
||||
"latitude": 51.5,
|
||||
"photos": [{"thumbnailUrl": "https://example.com/photo.jpg"}],
|
||||
"floorplans": [],
|
||||
"letFurnishType": "furnished",
|
||||
"letDateAvailable": "Now",
|
||||
"visible": True,
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr("listing_processor.detail_query", AsyncMock(return_value=sample_detail))
|
||||
|
||||
step = FetchListingDetailsStep(listing_repository, ListingType.RENT)
|
||||
result = await step.process(999)
|
||||
|
||||
assert result is not None
|
||||
assert result.id == 999
|
||||
assert result.price == 2000
|
||||
|
||||
# Verify it was persisted
|
||||
stored = await listing_repository.get_listings(only_ids=[999])
|
||||
assert len(stored) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_processor_marks_seen(
|
||||
listing_repository: ListingRepository,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
old_time = datetime(2020, 1, 1)
|
||||
listing = rent_listing_factory(id=50, last_seen=old_time)
|
||||
await listing_repository.upsert_listings([listing])
|
||||
|
||||
processor = ListingProcessor(listing_repository)
|
||||
|
||||
# Skip all steps so we only test mark_seen
|
||||
for step in processor.process_steps:
|
||||
step.needs_processing = AsyncMock(return_value=False)
|
||||
step.process = AsyncMock()
|
||||
|
||||
await processor.process_listing(50)
|
||||
|
||||
updated = await listing_repository.get_listings(only_ids=[50])
|
||||
assert len(updated) == 1
|
||||
# last_seen should have been updated to roughly now
|
||||
assert updated[0].last_seen > old_time
|
||||
122
tests/integration/test_repository_advanced.py
Normal file
122
tests/integration/test_repository_advanced.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""Advanced integration tests for ListingRepository."""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
|
||||
from models.listing import FurnishType, ListingType, QueryParameters
|
||||
from repositories.listing_repository import ListingRepository
|
||||
|
||||
|
||||
# ---------- Count and basic queries ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_matches_get_listings(seeded_repository: ListingRepository) -> None:
|
||||
qp = QueryParameters(listing_type=ListingType.RENT)
|
||||
count = seeded_repository.count_listings(qp)
|
||||
listings = await seeded_repository.get_listings(query_parameters=qp)
|
||||
assert count == len(listings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_with_small_page_size(seeded_repository: ListingRepository) -> None:
|
||||
qp = QueryParameters(listing_type=ListingType.RENT)
|
||||
rows = list(seeded_repository.stream_listings_optimized(qp, page_size=3))
|
||||
assert len(rows) == 10
|
||||
|
||||
|
||||
# ---------- Filter tests ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_bedrooms(seeded_repository: ListingRepository) -> None:
|
||||
qp = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=2, max_bedrooms=2)
|
||||
listings = await seeded_repository.get_listings(query_parameters=qp)
|
||||
for listing in listings:
|
||||
assert listing.number_of_bedrooms == 2
|
||||
assert len(listings) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_price_range(seeded_repository: ListingRepository) -> None:
|
||||
qp = QueryParameters(listing_type=ListingType.RENT, min_price=1500, max_price=2500)
|
||||
listings = await seeded_repository.get_listings(query_parameters=qp)
|
||||
for listing in listings:
|
||||
assert 1500 <= listing.price <= 2500
|
||||
assert len(listings) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_max_sqm(seeded_repository: ListingRepository) -> None:
|
||||
qp = QueryParameters(listing_type=ListingType.RENT, max_sqm=50)
|
||||
listings = await seeded_repository.get_listings(query_parameters=qp)
|
||||
for listing in listings:
|
||||
assert listing.square_meters is not None
|
||||
assert listing.square_meters <= 50
|
||||
assert len(listings) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_furnish_type(
|
||||
in_memory_engine: Engine,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
repo = ListingRepository(engine=in_memory_engine)
|
||||
furnished = rent_listing_factory(id=1, furnish_type=FurnishType.FURNISHED)
|
||||
unfurnished = rent_listing_factory(id=2, furnish_type=FurnishType.UNFURNISHED)
|
||||
await repo.upsert_listings([furnished, unfurnished])
|
||||
|
||||
qp = QueryParameters(
|
||||
listing_type=ListingType.RENT,
|
||||
furnish_types=[FurnishType.FURNISHED],
|
||||
)
|
||||
listings = await repo.get_listings(query_parameters=qp)
|
||||
assert len(listings) == 1
|
||||
assert listings[0].furnish_type == FurnishType.FURNISHED
|
||||
|
||||
|
||||
# ---------- Concurrency ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_upserts(
|
||||
in_memory_engine: Engine,
|
||||
rent_listing_factory,
|
||||
) -> None:
|
||||
repo = ListingRepository(engine=in_memory_engine)
|
||||
|
||||
async def upsert_batch(start_id: int) -> None:
|
||||
listings = [rent_listing_factory(id=start_id + i) for i in range(5)]
|
||||
await repo.upsert_listings(listings)
|
||||
|
||||
await asyncio.gather(
|
||||
upsert_batch(1000),
|
||||
upsert_batch(2000),
|
||||
upsert_batch(3000),
|
||||
upsert_batch(4000),
|
||||
upsert_batch(5000),
|
||||
)
|
||||
|
||||
qp = QueryParameters(listing_type=ListingType.RENT)
|
||||
total = repo.count_listings(qp)
|
||||
assert total == 25
|
||||
|
||||
|
||||
# ---------- Streaming ----------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_optimized_returns_dicts(seeded_repository: ListingRepository) -> None:
|
||||
qp = QueryParameters(listing_type=ListingType.RENT)
|
||||
rows = list(seeded_repository.stream_listings_optimized(qp))
|
||||
|
||||
assert len(rows) > 0
|
||||
for row in rows:
|
||||
assert isinstance(row, dict)
|
||||
assert "id" in row
|
||||
assert "price" in row
|
||||
assert "number_of_bedrooms" in row
|
||||
assert "square_meters" in row
|
||||
assert "longitude" in row
|
||||
assert "latitude" in row
|
||||
0
tests/regression/__init__.py
Normal file
0
tests/regression/__init__.py
Normal file
135
tests/regression/test_api_contracts.py
Normal file
135
tests/regression/test_api_contracts.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""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"
|
||||
)
|
||||
75
tests/regression/test_query_parameters.py
Normal file
75
tests/regression/test_query_parameters.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Regression tests for QueryParameters model and API query parsing."""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from unittest.mock import patch
|
||||
|
||||
from models.listing import QueryParameters, ListingType, FurnishType
|
||||
|
||||
|
||||
pytestmark = pytest.mark.regression
|
||||
|
||||
|
||||
class TestQueryParametersModel:
|
||||
def test_defaults_applied(self):
|
||||
params = QueryParameters(listing_type=ListingType.RENT)
|
||||
assert params.min_bedrooms == 1
|
||||
assert params.max_bedrooms == 999
|
||||
assert params.listing_type == ListingType.RENT
|
||||
|
||||
def test_datetime_z_suffix_parsing(self):
|
||||
params = QueryParameters(
|
||||
listing_type=ListingType.RENT,
|
||||
let_date_available_from="2024-01-15T00:00:00Z",
|
||||
)
|
||||
assert params.let_date_available_from is not None
|
||||
assert isinstance(params.let_date_available_from, datetime)
|
||||
|
||||
def test_datetime_offset_parsing(self):
|
||||
params = QueryParameters(
|
||||
listing_type=ListingType.RENT,
|
||||
let_date_available_from="2024-01-15T00:00:00+00:00",
|
||||
)
|
||||
assert params.let_date_available_from is not None
|
||||
assert isinstance(params.let_date_available_from, datetime)
|
||||
|
||||
def test_min_price_greater_than_max_raises(self):
|
||||
with pytest.raises((ValueError, Exception)):
|
||||
QueryParameters(
|
||||
listing_type=ListingType.RENT,
|
||||
min_price=5000,
|
||||
max_price=1000,
|
||||
)
|
||||
|
||||
def test_min_bedrooms_greater_than_max_raises(self):
|
||||
with pytest.raises((ValueError, Exception)):
|
||||
QueryParameters(
|
||||
listing_type=ListingType.RENT,
|
||||
min_bedrooms=5,
|
||||
max_bedrooms=2,
|
||||
)
|
||||
|
||||
|
||||
class TestQueryParametersApiParsing:
|
||||
@pytest.mark.asyncio
|
||||
async def test_comma_separated_furnish_types(self, async_client):
|
||||
response = await async_client.get(
|
||||
"/api/listing?listing_type=RENT&furnish_types=furnished,unfurnished"
|
||||
)
|
||||
# If the endpoint accepts the param, it should return 200
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_comma_separated_district_names(self, async_client):
|
||||
response = await async_client.get(
|
||||
"/api/listing?listing_type=RENT&district_names=London,Camden"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_listing_type_returns_422(self, async_client):
|
||||
response = await async_client.get(
|
||||
"/api/listing_geojson?listing_type=INVALID_TYPE"
|
||||
)
|
||||
assert response.status_code == 422
|
||||
34
tests/unit/test_district_service.py
Normal file
34
tests/unit/test_district_service.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Unit tests for services/district_service.py."""
|
||||
|
||||
import pytest
|
||||
|
||||
from services import district_service
|
||||
|
||||
|
||||
class TestGetAllDistricts:
|
||||
def test_get_all_districts_returns_dict(self):
|
||||
result = district_service.get_all_districts()
|
||||
assert isinstance(result, dict)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
class TestGetDistrictNames:
|
||||
def test_get_district_names_returns_list(self):
|
||||
result = district_service.get_district_names()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
class TestValidateDistricts:
|
||||
def test_validate_districts_all_valid(self):
|
||||
result = district_service.validate_districts(["London", "Westminster"])
|
||||
assert result == []
|
||||
|
||||
def test_validate_districts_returns_invalid(self):
|
||||
result = district_service.validate_districts(["London", "Narnia"])
|
||||
assert "Narnia" in result
|
||||
|
||||
def test_known_districts_present(self):
|
||||
names = district_service.get_district_names()
|
||||
for district in ["London", "Westminster", "Camden"]:
|
||||
assert district in names
|
||||
87
tests/unit/test_export_service.py
Normal file
87
tests/unit/test_export_service.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Unit tests for services/export_service.py."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from models.listing import QueryParameters, ListingType
|
||||
from services import export_service
|
||||
|
||||
|
||||
class TestExportToCsv:
|
||||
async def test_csv_export_calls_exporter(self, listing_repository, tmp_path):
|
||||
output_path = tmp_path / "output.csv"
|
||||
with patch("csv_exporter.export_to_csv", new_callable=AsyncMock) as mock_csv:
|
||||
mock_csv.return_value = None
|
||||
result = await export_service.export_to_csv(listing_repository, output_path)
|
||||
mock_csv.assert_called_once()
|
||||
assert result.success
|
||||
|
||||
async def test_csv_export_returns_correct_record_count(self, listing_repository, sample_rent_listings, tmp_path):
|
||||
await listing_repository.upsert_listings(sample_rent_listings)
|
||||
output_path = tmp_path / "output.csv"
|
||||
with patch("csv_exporter.export_to_csv", new_callable=AsyncMock) as mock_csv:
|
||||
mock_csv.return_value = None
|
||||
result = await export_service.export_to_csv(listing_repository, output_path)
|
||||
assert result.record_count == len(sample_rent_listings)
|
||||
|
||||
async def test_csv_export_passes_query_parameters(self, listing_repository, tmp_path):
|
||||
output_path = tmp_path / "output.csv"
|
||||
params = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=2)
|
||||
with patch("csv_exporter.export_to_csv", new_callable=AsyncMock) as mock_csv:
|
||||
mock_csv.return_value = None
|
||||
result = await export_service.export_to_csv(
|
||||
listing_repository, output_path, query_parameters=params
|
||||
)
|
||||
assert result.success
|
||||
assert str(output_path) in result.output_path
|
||||
|
||||
|
||||
class TestExportToGeojson:
|
||||
async def test_geojson_in_memory_returns_data(self, listing_repository):
|
||||
fake_geojson = {"type": "FeatureCollection", "features": [{"type": "Feature"}]}
|
||||
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
|
||||
mock_export.return_value = fake_geojson
|
||||
result = await export_service.export_to_geojson(listing_repository)
|
||||
assert result.data is not None
|
||||
assert result.data["type"] == "FeatureCollection"
|
||||
|
||||
async def test_geojson_file_export_returns_path(self, listing_repository, tmp_path):
|
||||
output_path = tmp_path / "output.geojson"
|
||||
fake_geojson = {"type": "FeatureCollection", "features": []}
|
||||
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
|
||||
mock_export.return_value = fake_geojson
|
||||
result = await export_service.export_to_geojson(
|
||||
listing_repository, output_path=output_path
|
||||
)
|
||||
assert result.output_path is not None
|
||||
assert result.data is None
|
||||
|
||||
async def test_geojson_with_filters(self, listing_repository):
|
||||
params = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=2)
|
||||
fake_geojson = {"type": "FeatureCollection", "features": []}
|
||||
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
|
||||
mock_export.return_value = fake_geojson
|
||||
result = await export_service.export_to_geojson(
|
||||
listing_repository, query_parameters=params
|
||||
)
|
||||
assert result.success
|
||||
mock_export.assert_called_once()
|
||||
|
||||
async def test_geojson_with_limit(self, listing_repository):
|
||||
fake_geojson = {"type": "FeatureCollection", "features": []}
|
||||
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
|
||||
mock_export.return_value = fake_geojson
|
||||
result = await export_service.export_to_geojson(
|
||||
listing_repository, limit=5
|
||||
)
|
||||
assert result.success
|
||||
_, kwargs = mock_export.call_args
|
||||
assert kwargs.get("limit") == 5
|
||||
|
||||
async def test_geojson_empty_data(self, listing_repository):
|
||||
fake_geojson = {"type": "FeatureCollection", "features": []}
|
||||
with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export:
|
||||
mock_export.return_value = fake_geojson
|
||||
result = await export_service.export_to_geojson(listing_repository)
|
||||
assert result.record_count == 0
|
||||
129
tests/unit/test_listing_service.py
Normal file
129
tests/unit/test_listing_service.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Unit tests for services/listing_service.py."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from models.listing import QueryParameters, ListingType
|
||||
from services import listing_service
|
||||
|
||||
|
||||
class TestGetListings:
|
||||
async def test_empty_db_returns_zero(self, listing_repository):
|
||||
result = await listing_service.get_listings(listing_repository)
|
||||
assert result.total_count == 0
|
||||
assert result.listings == []
|
||||
|
||||
async def test_with_results_returns_correct_count(self, listing_repository, sample_rent_listings):
|
||||
await listing_repository.upsert_listings(sample_rent_listings)
|
||||
result = await listing_service.get_listings(listing_repository)
|
||||
assert result.total_count == len(sample_rent_listings)
|
||||
assert len(result.listings) == len(sample_rent_listings)
|
||||
|
||||
async def test_with_query_parameters_filters(self, listing_repository, sample_rent_listings):
|
||||
await listing_repository.upsert_listings(sample_rent_listings)
|
||||
params = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=3)
|
||||
result = await listing_service.get_listings(listing_repository, query_parameters=params)
|
||||
for listing in result.listings:
|
||||
assert listing.number_of_bedrooms >= 3
|
||||
|
||||
async def test_limit_works(self, listing_repository, sample_rent_listings):
|
||||
await listing_repository.upsert_listings(sample_rent_listings)
|
||||
result = await listing_service.get_listings(listing_repository, limit=1)
|
||||
assert len(result.listings) <= 1
|
||||
|
||||
async def test_only_ids_works(self, listing_repository, sample_rent_listings):
|
||||
await listing_repository.upsert_listings(sample_rent_listings)
|
||||
target_ids = [sample_rent_listings[0].id]
|
||||
result = await listing_service.get_listings(listing_repository, only_ids=target_ids)
|
||||
assert all(l.id in target_ids for l in result.listings)
|
||||
|
||||
|
||||
class TestRefreshListings:
|
||||
async def test_async_mode_dispatches_celery_task(self, listing_repository):
|
||||
params = QueryParameters(listing_type=ListingType.RENT)
|
||||
with patch("tasks.listing_tasks.dump_listings_task") as mock_task:
|
||||
mock_task.apply_async.return_value = MagicMock(id="fake-task-id")
|
||||
result = await listing_service.refresh_listings(
|
||||
listing_repository, params, async_mode=True, user_email="test@example.com"
|
||||
)
|
||||
mock_task.apply_async.assert_called_once()
|
||||
assert result.task_id == "fake-task-id"
|
||||
|
||||
async def test_sync_mode_calls_fetcher(self, listing_repository):
|
||||
params = QueryParameters(listing_type=ListingType.RENT)
|
||||
with patch("services.listing_fetcher.dump_listings", new_callable=AsyncMock) as mock_dump:
|
||||
mock_dump.return_value = []
|
||||
result = await listing_service.refresh_listings(
|
||||
listing_repository, params, async_mode=False
|
||||
)
|
||||
mock_dump.assert_called_once()
|
||||
assert result.task_id is None
|
||||
|
||||
async def test_full_mode_calls_dump_listings_full(self, listing_repository):
|
||||
params = QueryParameters(listing_type=ListingType.RENT)
|
||||
with patch("services.listing_fetcher.dump_listings_full", new_callable=AsyncMock) as mock_full:
|
||||
mock_full.return_value = []
|
||||
result = await listing_service.refresh_listings(
|
||||
listing_repository, params, full=True, async_mode=False
|
||||
)
|
||||
mock_full.assert_called_once()
|
||||
|
||||
async def test_sync_returns_new_listings_count(self, listing_repository):
|
||||
params = QueryParameters(listing_type=ListingType.RENT)
|
||||
fake_listings = [MagicMock(), MagicMock(), MagicMock()]
|
||||
with patch("services.listing_fetcher.dump_listings", new_callable=AsyncMock) as mock_dump:
|
||||
mock_dump.return_value = fake_listings
|
||||
result = await listing_service.refresh_listings(
|
||||
listing_repository, params, async_mode=False
|
||||
)
|
||||
assert result.new_listings_count == 3
|
||||
|
||||
async def test_async_result_has_message(self, listing_repository):
|
||||
params = QueryParameters(listing_type=ListingType.RENT)
|
||||
with patch("tasks.listing_tasks.dump_listings_task") as mock_task:
|
||||
mock_task.apply_async.return_value = MagicMock(id="fake-task-id")
|
||||
result = await listing_service.refresh_listings(
|
||||
listing_repository, params, async_mode=True
|
||||
)
|
||||
assert result.message is not None
|
||||
assert len(result.message) > 0
|
||||
|
||||
|
||||
class TestDownloadImages:
|
||||
async def test_calls_image_fetcher(self, listing_repository):
|
||||
with patch("services.image_fetcher.dump_images", new_callable=AsyncMock) as mock_dump:
|
||||
mock_dump.return_value = None
|
||||
result = await listing_service.download_images(listing_repository)
|
||||
mock_dump.assert_called_once()
|
||||
|
||||
|
||||
class TestDetectFloorplans:
|
||||
async def test_calls_floorplan_detector(self, listing_repository):
|
||||
with patch("services.floorplan_detector.detect_floorplan", new_callable=AsyncMock) as mock_detect:
|
||||
mock_detect.return_value = None
|
||||
result = await listing_service.detect_floorplans(listing_repository)
|
||||
mock_detect.assert_called_once()
|
||||
|
||||
|
||||
class TestCalculateRoutes:
|
||||
async def test_passes_correct_travel_mode(self, listing_repository):
|
||||
with patch("services.route_calculator.calculate_route", new_callable=AsyncMock) as mock_calc:
|
||||
mock_calc.return_value = None
|
||||
result = await listing_service.calculate_routes(
|
||||
listing_repository,
|
||||
destination_address="London Bridge",
|
||||
travel_mode="TRANSIT",
|
||||
limit=10,
|
||||
)
|
||||
mock_calc.assert_called_once()
|
||||
|
||||
async def test_passes_limit(self, listing_repository):
|
||||
with patch("services.route_calculator.calculate_route", new_callable=AsyncMock) as mock_calc:
|
||||
mock_calc.return_value = None
|
||||
result = await listing_service.calculate_routes(
|
||||
listing_repository,
|
||||
destination_address="Kings Cross",
|
||||
travel_mode="TRANSIT",
|
||||
limit=5,
|
||||
)
|
||||
assert result == 5
|
||||
Loading…
Add table
Add a link
Reference in a new issue