diff --git a/api/app.py b/api/app.py index 8cefbea..7bdf5ff 100644 --- a/api/app.py +++ b/api/app.py @@ -7,6 +7,7 @@ from typing import Annotated, AsyncGenerator, Optional from api.auth import get_current_user from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS, APP_ENV from api.passkey_routes import passkey_router +from api.decision_routes import decision_router from api.poi_routes import poi_router from api.ws_routes import ws_router from api.rate_limit_config import RateLimitConfig @@ -100,6 +101,7 @@ app = FastAPI( ) app.include_router(passkey_router) app.include_router(poi_router) +app.include_router(decision_router) app.include_router(ws_router) init_metrics("realestate-crawler-api") app.mount("/metrics", get_metrics_asgi_app()) diff --git a/api/decision_routes.py b/api/decision_routes.py new file mode 100644 index 0000000..1ccf257 --- /dev/null +++ b/api/decision_routes.py @@ -0,0 +1,111 @@ +"""API routes for listing decisions (like/dislike).""" +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from api.auth import User, get_current_user +from database import engine +from repositories.decision_repository import DecisionRepository +from repositories.user_repository import UserRepository +from services import decision_service + +logger = logging.getLogger("uvicorn") + +decision_router = APIRouter(prefix="/api/decisions", tags=["decisions"]) + + +class SetDecisionRequest(BaseModel): + decision: str = Field(description="'liked' or 'disliked'") + listing_type: str = Field(default="RENT", description="'RENT' or 'BUY'") + + +class DecisionResponse(BaseModel): + id: int + listing_id: int + listing_type: str + decision: str + created_at: str + updated_at: str + + +def _get_user_id(user: User) -> int: + """Resolve auth User to database user ID.""" + user_repo = UserRepository(engine) + db_user = user_repo.get_user_by_email(user.email) + if db_user is None: + db_user = user_repo.create_user(user.email) + if db_user.id is None: + raise HTTPException(status_code=500, detail="Failed to create user") + return db_user.id + + +@decision_router.put("/{listing_id}", response_model=DecisionResponse) +async def set_decision( + user: Annotated[User, Depends(get_current_user)], + listing_id: int, + body: SetDecisionRequest, +) -> DecisionResponse: + """Set a decision (like/dislike) on a listing.""" + user_id = _get_user_id(user) + repo = DecisionRepository(engine) + try: + result = decision_service.set_decision( + repo, + user_id=user_id, + listing_id=listing_id, + listing_type=body.listing_type, + decision=body.decision, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return DecisionResponse( + id=result.id, # type: ignore[arg-type] + listing_id=result.listing_id, + listing_type=result.listing_type, + decision=result.decision, + created_at=result.created_at.isoformat(), + updated_at=result.updated_at.isoformat(), + ) + + +@decision_router.get("", response_model=list[DecisionResponse]) +async def get_decisions( + user: Annotated[User, Depends(get_current_user)], +) -> list[DecisionResponse]: + """Get all decisions for the current user.""" + user_id = _get_user_id(user) + repo = DecisionRepository(engine) + decisions = decision_service.get_decisions(repo, user_id=user_id) + return [ + DecisionResponse( + id=d.id, # type: ignore[arg-type] + listing_id=d.listing_id, + listing_type=d.listing_type, + decision=d.decision, + created_at=d.created_at.isoformat(), + updated_at=d.updated_at.isoformat(), + ) + for d in decisions + ] + + +@decision_router.delete("/{listing_id}") +async def delete_decision( + user: Annotated[User, Depends(get_current_user)], + listing_id: int, + listing_type: str = "RENT", +) -> dict[str, bool]: + """Remove a decision (back to neutral).""" + user_id = _get_user_id(user) + repo = DecisionRepository(engine) + deleted = decision_service.clear_decision( + repo, + user_id=user_id, + listing_id=listing_id, + listing_type=listing_type, + ) + if not deleted: + raise HTTPException(status_code=404, detail="Decision not found") + return {"success": True} diff --git a/tests/unit/test_decision_routes.py b/tests/unit/test_decision_routes.py new file mode 100644 index 0000000..622b4a0 --- /dev/null +++ b/tests/unit/test_decision_routes.py @@ -0,0 +1,128 @@ +"""Unit tests for decision API routes.""" +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import Engine +from sqlmodel import SQLModel, Session, create_engine + +from models.user import User +from models.decision import ListingDecision # noqa: F401 - needed for table creation +from api.auth import get_current_user, User as AuthUser + + +@pytest.fixture +def decision_engine() -> Engine: + engine = create_engine( + "sqlite:///:memory:", + echo=False, + connect_args={"check_same_thread": False}, + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + session.add(User(id=1, email="test@example.com")) + session.commit() + yield engine # type: ignore[misc] + SQLModel.metadata.drop_all(engine) + + +@pytest.fixture +async def client(decision_engine: Engine) -> AsyncClient: + import database + import api.app as api_app + import api.decision_routes as decision_routes_mod + import api.poi_routes as poi_routes_mod + + app = api_app.app + mock_user = AuthUser( + sub="test-user-id", email="test@example.com", name="Test User" + ) + app.dependency_overrides[get_current_user] = lambda: mock_user + + original_db = database.engine + original_app = api_app.engine + original_decision = decision_routes_mod.engine + original_poi = poi_routes_mod.engine + database.engine = decision_engine + api_app.engine = decision_engine + decision_routes_mod.engine = decision_engine + poi_routes_mod.engine = decision_engine + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c # type: ignore[misc] + + database.engine = original_db + api_app.engine = original_app + decision_routes_mod.engine = original_decision + poi_routes_mod.engine = original_poi + app.dependency_overrides.clear() + + +class TestDecisionRoutes: + @pytest.mark.asyncio + async def test_set_decision(self, client: AsyncClient) -> None: + resp = await client.put( + "/api/decisions/100", + json={"decision": "liked", "listing_type": "RENT"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["decision"] == "liked" + assert data["listing_id"] == 100 + + @pytest.mark.asyncio + async def test_get_decisions(self, client: AsyncClient) -> None: + await client.put( + "/api/decisions/100", + json={"decision": "liked", "listing_type": "RENT"}, + ) + resp = await client.get("/api/decisions") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + + @pytest.mark.asyncio + async def test_delete_decision(self, client: AsyncClient) -> None: + await client.put( + "/api/decisions/100", + json={"decision": "liked", "listing_type": "RENT"}, + ) + resp = await client.delete( + "/api/decisions/100", params={"listing_type": "RENT"} + ) + assert resp.status_code == 200 + assert resp.json()["success"] is True + + @pytest.mark.asyncio + async def test_delete_nonexistent_returns_404( + self, client: AsyncClient + ) -> None: + resp = await client.delete( + "/api/decisions/999", params={"listing_type": "RENT"} + ) + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_invalid_decision_returns_400( + self, client: AsyncClient + ) -> None: + resp = await client.put( + "/api/decisions/100", + json={"decision": "maybe", "listing_type": "RENT"}, + ) + assert resp.status_code == 400 + + @pytest.mark.asyncio + async def test_update_decision(self, client: AsyncClient) -> None: + await client.put( + "/api/decisions/100", + json={"decision": "liked", "listing_type": "RENT"}, + ) + resp = await client.put( + "/api/decisions/100", + json={"decision": "disliked", "listing_type": "RENT"}, + ) + assert resp.status_code == 200 + assert resp.json()["decision"] == "disliked" + # Still only one decision + resp2 = await client.get("/api/decisions") + assert len(resp2.json()) == 1