feat: add decision API routes with tests
PUT /api/decisions/{listing_id} to set decision,
GET /api/decisions to list all user decisions,
DELETE /api/decisions/{listing_id} to remove a decision.
All 6 API route tests pass.
This commit is contained in:
parent
d350b806ba
commit
341de89004
3 changed files with 241 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ from typing import Annotated, AsyncGenerator, Optional
|
||||||
from api.auth import get_current_user
|
from api.auth import get_current_user
|
||||||
from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS, APP_ENV
|
from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS, APP_ENV
|
||||||
from api.passkey_routes import passkey_router
|
from api.passkey_routes import passkey_router
|
||||||
|
from api.decision_routes import decision_router
|
||||||
from api.poi_routes import poi_router
|
from api.poi_routes import poi_router
|
||||||
from api.ws_routes import ws_router
|
from api.ws_routes import ws_router
|
||||||
from api.rate_limit_config import RateLimitConfig
|
from api.rate_limit_config import RateLimitConfig
|
||||||
|
|
@ -100,6 +101,7 @@ app = FastAPI(
|
||||||
)
|
)
|
||||||
app.include_router(passkey_router)
|
app.include_router(passkey_router)
|
||||||
app.include_router(poi_router)
|
app.include_router(poi_router)
|
||||||
|
app.include_router(decision_router)
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
init_metrics("realestate-crawler-api")
|
init_metrics("realestate-crawler-api")
|
||||||
app.mount("/metrics", get_metrics_asgi_app())
|
app.mount("/metrics", get_metrics_asgi_app())
|
||||||
|
|
|
||||||
111
api/decision_routes.py
Normal file
111
api/decision_routes.py
Normal file
|
|
@ -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}
|
||||||
128
tests/unit/test_decision_routes.py
Normal file
128
tests/unit/test_decision_routes.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue