Skip to content

Commit e04b044

Browse files
vrajpatelllDevasygoogle-labs-jules[bot]
authored
feat(user): add user services endpoints from user service docs (#11)
* feat(user): add user services endpoints from user service docs * Fix test errors and address deprecation warnings - Resolved ModuleNotFoundErrors in tests by adding project root to sys.path in conftest.py. - Fixed ResponseValidationError in user routes tests by aligning mock data with schema (camelCase for datetime fields). - Addressed Pydantic V2 deprecations (Config class to model_config, .dict() to .model_dump()). - Replaced deprecated FastAPI on_event handlers with lifespan manager. - Replaced deprecated datetime.utcnow() with datetime.now(timezone.utc). - Updated test invocation to `python -m pytest` to resolve plugin discovery issues in the environment. (#12) * feat: Add tests for user service routes and methods Implemented tests for the user service, covering both API routes and underlying service logic. Key changes and steps taken: 1. Created `backend/tests/user/` directory. 2. Added `test_user_routes.py` for testing `/users/me` endpoints (GET, PATCH, DELETE). - Initialized with `httpx.AsyncClient`, later refactored to `fastapi.testclient.TestClient` for compatibility with FastAPI app instance. - Corrected import paths for the FastAPI `app` instance (from `main.py`). - Ensured mock data for `created_at` and `updated_at` fields uses `datetime` objects to match Pydantic model validation. - Added missing `datetime` import. 3. Added `test_user_service.py` for testing `UserService` methods. - Implemented tests for `get_user_by_id`, `update_user_profile`, `delete_user`, and `transform_user_document`. - Used `pytest-mock` for mocking database interactions. - Corrected an `InvalidId` error by using a validly formatted ObjectId string for a non-existent user test case. 4. Updated `backend/requirements.txt` to include `pytest-mock`. 5. Modified `backend/app/user/schemas.py` to add an alias for `avatar` to `imageUrl` in `UserProfileResponse`. 6. Iteratively debugged test execution issues: - Resolved `pytest-asyncio` import problems by using `python -m pytest`. - Fixed `ModuleNotFoundError` for the `app` module by running pytest from the `backend` directory. - Addressed various `TypeError` and `NameError` issues during test setup and execution. The tests should now be in a state where they are close to passing or all passing. The primary focus was on setting up the test structure, writing comprehensive tests, and resolving environment and import-related issues. * Fix test errors and address deprecation warnings - Resolved ModuleNotFoundErrors in tests by adding project root to sys.path in conftest.py. - Fixed ResponseValidationError in user routes tests by aligning mock data with schema (camelCase for datetime fields). - Addressed Pydantic V2 deprecations (Config class to model_config, .dict() to .model_dump()). - Replaced deprecated FastAPI on_event handlers with lifespan manager. - Replaced deprecated datetime.utcnow() with datetime.now(timezone.utc). - Updated test invocation to `python -m pytest` to resolve plugin discovery issues in the environment. --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> * Fix: Resolve Unprocessable Entity error in Swagger UI authorization (#13) - Creates a new /auth/token endpoint that accepts OAuth2PasswordRequestForm (form-data) as expected by Swagger UI for the tokenUrl. - Updates OAuth2PasswordBearer to use this new /auth/token endpoint. - The existing /auth/login/email endpoint (expecting JSON) remains unchanged for direct client use. This change ensures that Swagger UI can correctly authenticate and obtain a bearer token without a 422 Unprocessable Entity error, as it now communicates with an endpoint that handles the expected 'application/x-www-form-urlencoded' content type for token acquisition. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> * fix(user): handle invalid ObjectId gracefully in user service methods * fix(user): correct key name for avatar in user document transformation --------- Co-authored-by: Devasy Patel <[email protected]> Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent 4bbf4cd commit e04b044

File tree

12 files changed

+570
-32
lines changed

12 files changed

+570
-32
lines changed

backend/app/auth/routes.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,43 @@
66
UserResponse, ErrorResponse
77
)
88
from app.auth.service import auth_service
9-
from app.auth.security import create_access_token
9+
from app.auth.security import create_access_token, oauth2_scheme # Import oauth2_scheme
10+
from fastapi.security import OAuth2PasswordRequestForm # Import OAuth2PasswordRequestForm
1011
from datetime import timedelta
1112
from app.config import settings
1213

1314
router = APIRouter(prefix="/auth", tags=["Authentication"])
1415

16+
@router.post("/token", response_model=TokenResponse, include_in_schema=False) # include_in_schema=False to hide from docs if desired, or True to show
17+
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
18+
"""
19+
OAuth2 compatible token login, get an access token for future requests.
20+
This endpoint is used by Swagger UI for authorization.
21+
It expects username (email) and password in form-data.
22+
"""
23+
try:
24+
# Note: OAuth2PasswordRequestForm uses 'username' field for the user identifier.
25+
# We'll treat it as email here.
26+
result = await auth_service.authenticate_user_with_email(
27+
email=form_data.username, # form_data.username is the email
28+
password=form_data.password
29+
)
30+
31+
access_token = create_access_token(
32+
data={"sub": str(result["user"]["_id"])},
33+
expires_delta=timedelta(minutes=settings.access_token_expire_minutes)
34+
)
35+
36+
return TokenResponse(access_token=access_token, token_type="bearer")
37+
except HTTPException:
38+
raise
39+
except Exception as e:
40+
# It's good practice to log the exception here
41+
raise HTTPException(
42+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
43+
detail=f"Authentication failed: {str(e)}"
44+
)
45+
1546
@router.post("/signup/email", response_model=AuthResponse)
1647
async def signup_with_email(request: EmailSignupRequest):
1748
"""

backend/app/auth/security.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
from datetime import datetime, timedelta
1+
from datetime import datetime, timedelta, timezone
22
from typing import Optional, Dict, Any
33
from jose import JWTError, jwt
44
from passlib.context import CryptContext
5-
from fastapi import HTTPException, status
5+
from fastapi import HTTPException, status, Depends
6+
from fastapi.security import OAuth2PasswordBearer
67
from app.config import settings
78
import secrets
89

@@ -13,6 +14,8 @@
1314
# Fallback for bcrypt version compatibility issues
1415
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=12)
1516

17+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") # Updated tokenUrl
18+
1619
def verify_password(plain_password: str, hashed_password: str) -> bool:
1720
"""
1821
Verifies whether a plaintext password matches a given hashed password.
@@ -53,9 +56,9 @@ def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta]
5356
"""
5457
to_encode = data.copy()
5558
if expires_delta:
56-
expire = datetime.utcnow() + expires_delta
59+
expire = datetime.now(timezone.utc) + expires_delta
5760
else:
58-
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
61+
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
5962

6063
to_encode.update({"exp": expire, "type": "access"})
6164
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
@@ -95,3 +98,22 @@ def generate_reset_token() -> str:
9598
A random 32-byte URL-safe string suitable for use as a password reset token.
9699
"""
97100
return secrets.token_urlsafe(32)
101+
102+
def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]:
103+
"""
104+
Retrieves the current user based on the provided JWT token using centralized verification.
105+
106+
Args:
107+
token: The JWT token from which to extract the user information.
108+
109+
Returns:
110+
A dictionary containing the current user's information.
111+
112+
Raises:
113+
HTTPException: If the token is invalid or user information cannot be extracted.
114+
"""
115+
payload = verify_token(token) # Centralized JWT validation and error handling
116+
user_id = payload.get("sub")
117+
if user_id is None:
118+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
119+
return {"_id": user_id}

backend/app/auth/service.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime, timedelta
1+
from datetime import datetime, timedelta, timezone
22
from typing import Optional, Dict, Any
33
from pymongo.errors import DuplicateKeyError
44
from bson import ObjectId
@@ -95,7 +95,7 @@ async def create_user_with_email(self, email: str, password: str, name: str) ->
9595
"name": name,
9696
"avatar": None,
9797
"currency": "USD",
98-
"created_at": datetime.utcnow(),
98+
"created_at": datetime.now(timezone.utc),
9999
"auth_provider": "email",
100100
"firebase_uid": None
101101
}
@@ -198,7 +198,7 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]:
198198
"name": name,
199199
"avatar": picture,
200200
"currency": "USD",
201-
"created_at": datetime.utcnow(),
201+
"created_at": datetime.now(timezone.utc),
202202
"auth_provider": "google",
203203
"firebase_uid": firebase_uid,
204204
"hashed_password": None
@@ -245,7 +245,7 @@ async def refresh_access_token(self, refresh_token: str) -> str:
245245
token_record = await db.refresh_tokens.find_one({
246246
"token": refresh_token,
247247
"revoked": False,
248-
"expires_at": {"$gt": datetime.utcnow()}
248+
"expires_at": {"$gt": datetime.now(timezone.utc)}
249249
})
250250

251251
if not token_record:
@@ -322,7 +322,7 @@ async def request_password_reset(self, email: str) -> bool:
322322

323323
# Generate reset token
324324
reset_token = generate_reset_token()
325-
reset_expires = datetime.utcnow() + timedelta(hours=1) # 1 hour expiry
325+
reset_expires = datetime.now(timezone.utc) + timedelta(hours=1) # 1 hour expiry
326326

327327
# Store reset token
328328
await db.password_resets.insert_one({
@@ -362,7 +362,7 @@ async def confirm_password_reset(self, reset_token: str, new_password: str) -> b
362362
reset_record = await db.password_resets.find_one({
363363
"token": reset_token,
364364
"used": False,
365-
"expires_at": {"$gt": datetime.utcnow()}
365+
"expires_at": {"$gt": datetime.now(timezone.utc)}
366366
})
367367

368368
if not reset_record:
@@ -406,14 +406,14 @@ async def _create_refresh_token_record(self, user_id: str) -> str:
406406
db = self.get_db()
407407

408408
refresh_token = create_refresh_token()
409-
expires_at = datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days)
409+
expires_at = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
410410

411411
await db.refresh_tokens.insert_one({
412412
"token": refresh_token,
413413
"user_id": ObjectId(user_id) if isinstance(user_id, str) else user_id,
414414
"expires_at": expires_at,
415415
"revoked": False,
416-
"created_at": datetime.utcnow()
416+
"created_at": datetime.now(timezone.utc)
417417
})
418418

419419
return refresh_token

backend/app/user/routes.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from fastapi import APIRouter, Depends, HTTPException, status
2+
from app.user.schemas import UserProfileResponse, UserProfileUpdateRequest, DeleteUserResponse
3+
from app.user.service import user_service
4+
from app.auth.security import get_current_user
5+
from typing import Dict, Any
6+
7+
router = APIRouter(prefix="/users", tags=["User"])
8+
9+
@router.get("/me", response_model=UserProfileResponse)
10+
async def get_current_user_profile(current_user: Dict[str, Any] = Depends(get_current_user)):
11+
user = await user_service.get_user_by_id(current_user["_id"])
12+
if not user:
13+
raise HTTPException(status_code=404, detail="User not found")
14+
return user
15+
16+
@router.patch("/me", response_model=Dict[str, Any])
17+
async def update_user_profile(
18+
updates: UserProfileUpdateRequest,
19+
current_user: Dict[str, Any] = Depends(get_current_user)
20+
):
21+
update_data = updates.model_dump(exclude_unset=True)
22+
if not update_data:
23+
raise HTTPException(status_code=400, detail="No update fields provided.")
24+
updated_user = await user_service.update_user_profile(current_user["_id"], update_data)
25+
if not updated_user:
26+
raise HTTPException(status_code=404, detail="User not found")
27+
return {"user": updated_user}
28+
29+
@router.delete("/me", response_model=DeleteUserResponse)
30+
async def delete_user_account(current_user: Dict[str, Any] = Depends(get_current_user)):
31+
deleted = await user_service.delete_user(current_user["_id"])
32+
if not deleted:
33+
raise HTTPException(status_code=404, detail="User not found")
34+
return DeleteUserResponse(success=True, message="User account scheduled for deletion.")

backend/app/user/schemas.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from pydantic import BaseModel, EmailStr, Field
2+
from typing import Optional
3+
from datetime import datetime
4+
5+
class UserProfileResponse(BaseModel):
6+
id: str = Field(alias="_id")
7+
name: str
8+
email: EmailStr
9+
imageUrl: Optional[str] = Field(default=None, alias="avatar")
10+
currency: str = "USD"
11+
createdAt: datetime
12+
updatedAt: datetime
13+
14+
model_config = {"populate_by_name": True}
15+
16+
class UserProfileUpdateRequest(BaseModel):
17+
name: Optional[str] = None
18+
imageUrl: Optional[str] = None
19+
currency: Optional[str] = None
20+
21+
class DeleteUserResponse(BaseModel):
22+
success: bool = True
23+
message: Optional[str] = None

backend/app/user/service.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from fastapi import HTTPException, status, Depends
2+
from app.database import get_database
3+
from bson import ObjectId
4+
from datetime import datetime, timezone
5+
from typing import Optional, Dict, Any
6+
7+
class UserService:
8+
def __init__(self):
9+
pass
10+
11+
def get_db(self):
12+
return get_database()
13+
14+
def transform_user_document(self, user: dict) -> dict:
15+
if not user:
16+
return None
17+
try:
18+
user_id = str(user["_id"])
19+
except Exception:
20+
return None # Handle invalid ObjectId gracefully
21+
return {
22+
"_id": user_id,
23+
"name": user.get("name"),
24+
"email": user.get("email"),
25+
"avatar": user.get("imageUrl") or user.get("avatar"),
26+
"currency": user.get("currency", "USD"),
27+
"createdAt": user.get("created_at"),
28+
"updatedAt": user.get("updated_at") or user.get("created_at"),
29+
}
30+
31+
async def get_user_by_id(self, user_id: str) -> Optional[dict]:
32+
db = self.get_db()
33+
try:
34+
obj_id = ObjectId(user_id)
35+
except Exception:
36+
return None # Handle invalid ObjectId gracefully
37+
user = await db.users.find_one({"_id": obj_id})
38+
return self.transform_user_document(user)
39+
40+
async def update_user_profile(self, user_id: str, updates: dict) -> Optional[dict]:
41+
db = self.get_db()
42+
try:
43+
obj_id = ObjectId(user_id)
44+
except Exception:
45+
return None # Handle invalid ObjectId gracefully
46+
updates["updated_at"] = datetime.now(timezone.utc)
47+
result = await db.users.find_one_and_update(
48+
{"_id": obj_id},
49+
{"$set": updates},
50+
return_document=True
51+
)
52+
return self.transform_user_document(result)
53+
54+
async def delete_user(self, user_id: str) -> bool:
55+
db = self.get_db()
56+
try:
57+
obj_id = ObjectId(user_id)
58+
except Exception:
59+
return False # Handle invalid ObjectId gracefully
60+
result = await db.users.delete_one({"_id": obj_id})
61+
return result.deleted_count == 1
62+
63+
user_service = UserService()

backend/main.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,31 @@
11
from fastapi import FastAPI, HTTPException, Request
22
from fastapi.middleware.cors import CORSMiddleware
33
from fastapi.responses import Response
4+
from contextlib import asynccontextmanager
45
from app.database import connect_to_mongo, close_mongo_connection
56
from app.auth.routes import router as auth_router
7+
from app.user.routes import router as user_router
68
from app.config import settings
79

10+
@asynccontextmanager
11+
async def lifespan(app: FastAPI):
12+
# Startup
13+
print("Lifespan: Connecting to MongoDB...")
14+
await connect_to_mongo()
15+
print("Lifespan: MongoDB connected.")
16+
yield
17+
# Shutdown
18+
print("Lifespan: Closing MongoDB connection...")
19+
await close_mongo_connection()
20+
print("Lifespan: MongoDB connection closed.")
21+
822
app = FastAPI(
923
title="Splitwiser API",
1024
description="Backend API for Splitwiser expense tracking application",
1125
version="1.0.0",
1226
docs_url="/docs",
13-
redoc_url="/redoc"
27+
redoc_url="/redoc",
28+
lifespan=lifespan
1429
)
1530

1631
# CORS middleware - Enhanced configuration for production
@@ -74,21 +89,6 @@ async def options_handler(request: Request, path: str):
7489

7590
return response
7691

77-
# Database events
78-
@app.on_event("startup")
79-
async def startup_event():
80-
"""
81-
Initializes the MongoDB connection when the application starts.
82-
"""
83-
await connect_to_mongo()
84-
85-
@app.on_event("shutdown")
86-
async def shutdown_event():
87-
"""
88-
Closes the MongoDB connection when the application shuts down.
89-
"""
90-
await close_mongo_connection()
91-
9292
# Health check
9393
@app.get("/health")
9494
async def health_check():
@@ -101,6 +101,7 @@ async def health_check():
101101

102102
# Include routers
103103
app.include_router(auth_router)
104+
app.include_router(user_router)
104105

105106
if __name__ == "__main__":
106107
import uvicorn

backend/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ httpx
1717
mongomock-motor
1818
pytest-env
1919
pytest-cov
20+
pytest-mock

backend/tests/auth/test_auth_routes.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from main import app # Assuming your FastAPI app instance is here
55
from app.config import settings # To potentially override settings if needed, or check values
66
from app.auth.security import verify_password, get_password_hash # For checking hashed password if necessary
7-
from datetime import datetime
7+
from datetime import datetime, timezone
88
from bson import ObjectId
99

1010
# It's good practice to set a specific test secret key if not relying on external env vars
@@ -135,7 +135,7 @@ async def test_login_with_email_success(mock_db):
135135
"name": "Login User",
136136
"avatar": None,
137137
"currency": "USD",
138-
"created_at": datetime.utcnow(), # Ensure datetime is used
138+
"created_at": datetime.now(timezone.utc), # Ensure datetime is used
139139
"auth_provider": "email",
140140
"firebase_uid": None
141141
})
@@ -173,7 +173,7 @@ async def test_login_with_incorrect_password(mock_db):
173173
"email": user_email,
174174
"hashed_password": get_password_hash(correct_password),
175175
"name": "Wrong Pass User",
176-
"created_at": datetime.utcnow()
176+
"created_at": datetime.now(timezone.utc)
177177
})
178178

179179
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:

0 commit comments

Comments
 (0)