From e52ab0188c6773081889c41703783eb857c3d5b3 Mon Sep 17 00:00:00 2001 From: Samudra Date: Sat, 26 Jul 2025 11:37:03 +0530 Subject: [PATCH 1/5] Improve exception handling and unit tests for auth, user, groups and expenses services --- backend/.coverage | Bin 0 -> 159744 bytes backend/app/auth/service.py | 309 +++++--- backend/app/expenses/service.py | 93 ++- backend/app/groups/service.py | 39 +- backend/app/user/service.py | 18 +- backend/tests/auth/test_auth_service.py | 660 ++++++++++++++++++ .../tests/expenses/test_expense_service.py | 125 +++- backend/tests/groups/test_groups_service.py | 34 + backend/tests/user/test_user_service.py | 34 + 9 files changed, 1156 insertions(+), 156 deletions(-) create mode 100644 backend/.coverage create mode 100644 backend/tests/auth/test_auth_service.py diff --git a/backend/.coverage b/backend/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..2f88927bdabb2dc69b24bdfbdd9f644d2219e4ea GIT binary patch literal 159744 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 ZG9L)uc#?Tv@P>_W&)s*6YFzW~{{RZeYC8Y` literal 0 HcmV?d00001 diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 63a96e10..c2ae8de8 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -1,7 +1,8 @@ from datetime import datetime, timedelta, timezone from typing import Optional, Dict, Any -from pymongo.errors import DuplicateKeyError +from pymongo.errors import DuplicateKeyError, PyMongoError from bson import ObjectId +from jose import JWTError from fastapi import HTTPException, status from app.database import get_database from app.auth.security import get_password_hash, verify_password, create_refresh_token, generate_reset_token @@ -11,6 +12,9 @@ from app.config import settings import os import json +import logging + +logger = logging.getLogger(__name__) # Initialize Firebase Admin SDK if not firebase_admin._apps: @@ -116,6 +120,12 @@ async def create_user_with_email(self, email: str, password: str, name: str) -> status_code=status.HTTP_400_BAD_REQUEST, detail="User with this email already exists" ) + except Exception as e: + logger.exception("Unexpected error while creating user with email") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) async def authenticate_user_with_email(self, email: str, password: str) -> Dict[str, Any]: """ @@ -127,17 +137,31 @@ async def authenticate_user_with_email(self, email: str, password: str) -> Dict[ A dictionary containing the authenticated user and a new refresh token. """ db = self.get_db() - - user = await db.users.find_one({"email": email}) + try: + user = await db.users.find_one({"email": email}) + except PyMongoError as e: + logger.error(f"Database error during user lookup: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) + if not user or not verify_password(password, user.get("hashed_password", "")): + logger.info("Authentication failed due to invalid credentials.") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password" ) - + # Create new refresh token - refresh_token = await self._create_refresh_token_record(str(user["_id"])) - + try: + refresh_token = await self._create_refresh_token_record(str(user["_id"])) + except Exception as e: + logger.error(f"Failed to generate refresh token: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to generate refresh token" + ) return { "user": user, "refresh_token": refresh_token @@ -157,7 +181,14 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: """ try: # Verify the Firebase ID token - decoded_token = firebase_auth.verify_id_token(id_token) + try: + decoded_token = firebase_auth.verify_id_token(id_token) + except firebase_auth.InvalidIdTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Google ID token" + ) + firebase_uid = decoded_token['uid'] email = decoded_token.get('email') name = decoded_token.get('name', email.split('@')[0] if email else 'User') @@ -172,11 +203,17 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: db = self.get_db() # Check if user exists - user = await db.users.find_one({"$or": [ - {"email": email}, - {"firebase_uid": firebase_uid} - ]}) - + try: + user = await db.users.find_one({"$or": [ + {"email": email}, + {"firebase_uid": firebase_uid} + ]}) + except PyMongoError as e: + logger.error("Database error while checking user: %s", str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) if user: # Update user info if needed update_data = {} @@ -186,11 +223,14 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: update_data["avatar"] = picture if update_data: - await db.users.update_one( - {"_id": user["_id"]}, - {"$set": update_data} - ) - user.update(update_data) + try: + await db.users.update_one( + {"_id": user["_id"]}, + {"$set": update_data} + ) + user.update(update_data) + except PyMongoError as e: + logger.warning("Failed to update user profile: %s", str(e)) else: # Create new user user_doc = { @@ -203,25 +243,35 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: "firebase_uid": firebase_uid, "hashed_password": None } - - result = await db.users.insert_one(user_doc) - user_doc["_id"] = result.inserted_id - user = user_doc + try: + result = await db.users.insert_one(user_doc) + user_doc["_id"] = result.inserted_id + user = user_doc + except PyMongoError as e: + logger.error("Failed to create new Google user: %s", str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create user" + ) # Create refresh token - refresh_token = await self._create_refresh_token_record(str(user["_id"])) - + try: + refresh_token = await self._create_refresh_token_record(str(user["_id"])) + except Exception as e: + logger.error("Failed to issue refresh token for Google login: %s", str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to generate refresh token" + ) + return { "user": user, "refresh_token": refresh_token } - - except firebase_auth.InvalidIdTokenError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid Google ID token" - ) + except HTTPException: + raise except Exception as e: + logger.exception("Unexpected error during Google authentication") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Google authentication failed: {str(e)}" @@ -242,11 +292,18 @@ async def refresh_access_token(self, refresh_token: str) -> str: db = self.get_db() # Find and validate refresh token - token_record = await db.refresh_tokens.find_one({ - "token": refresh_token, - "revoked": False, - "expires_at": {"$gt": datetime.now(timezone.utc)} - }) + try: + token_record = await db.refresh_tokens.find_one({ + "token": refresh_token, + "revoked": False, + "expires_at": {"$gt": datetime.now(timezone.utc)} + }) + except PyMongoError as e: + logger.error("Database error while validating refresh token: %s", str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) if not token_record: raise HTTPException( @@ -255,7 +312,14 @@ async def refresh_access_token(self, refresh_token: str) -> str: ) # Get user - user = await db.users.find_one({"_id": token_record["user_id"]}) + try: + user = await db.users.find_one({"_id": token_record["user_id"]}) + except PyMongoError as e: + logger.error("Error while fetching user: %s", str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -263,15 +327,27 @@ async def refresh_access_token(self, refresh_token: str) -> str: ) # Create new refresh token (token rotation) - new_refresh_token = await self._create_refresh_token_record(str(user["_id"])) + try: + new_refresh_token = await self._create_refresh_token_record(str(user["_id"])) + except Exception as e: + logger.error("Failed to create new refresh token: %s", str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create refresh token" + ) # Revoke old token - await db.refresh_tokens.update_one( - {"_id": token_record["_id"]}, - {"$set": {"revoked": True}} - ) - + try: + await db.refresh_tokens.update_one( + {"_id": token_record["_id"]}, + {"$set": {"revoked": True}} + ) + except PyMongoError as e: + logger.error("Failed to revoke old refresh token: %s", str(e)) + # No raise here since new token is safely issued + return new_refresh_token + async def verify_access_token(self, token: str) -> Dict[str, Any]: """ Verifies an access token and retrieves the associated user. @@ -287,9 +363,16 @@ async def verify_access_token(self, token: str) -> Dict[str, Any]: """ from app.auth.security import verify_token - payload = verify_token(token) - user_id = payload.get("sub") - + try: + payload = verify_token(token) + user_id = payload.get("sub") + except JWTError as e: + logger.warning("JWT verification failed: %s", str(e)) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token" + ) + if not user_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -297,7 +380,15 @@ async def verify_access_token(self, token: str) -> Dict[str, Any]: ) db = self.get_db() - user = await db.users.find_one({"_id": user_id}) + + try: + user = await db.users.find_one({"_id": user_id}) + except Exception as e: + logger.error("Error while verifying token: %s", str(e)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error" + ) if not user: raise HTTPException( @@ -315,7 +406,12 @@ async def request_password_reset(self, email: str) -> bool: """ db = self.get_db() - user = await db.users.find_one({"email": email}) + try: + user = await db.users.find_one({"email": email}) + except PyMongoError as e: + logger.error(f"Database error while fetching user by email {email}: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error during user lookup.") + if not user: # Don't reveal if email exists or not return True @@ -324,19 +420,23 @@ async def request_password_reset(self, email: str) -> bool: reset_token = generate_reset_token() reset_expires = datetime.now(timezone.utc) + timedelta(hours=1) # 1 hour expiry - # Store reset token - await db.password_resets.insert_one({ - "user_id": user["_id"], - "token": reset_token, - "expires_at": reset_expires, - "used": False, - "created_at": datetime.utcnow() - }) + try: + # Store reset token + await db.password_resets.insert_one({ + "user_id": user["_id"], + "token": reset_token, + "expires_at": reset_expires, + "used": False, + "created_at": datetime.utcnow() + }) + except PyMongoError as e: + logger.error(f"Database error while storing reset token for user {email}: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error during token storage.") # For development/free tier: just log the reset token # In production, you would send this via email - print(f"Password reset token for {email}: {reset_token}") - print(f"Reset link: https://yourapp.com/reset-password?token={reset_token}") + logger.info(f"Password reset token for {email}: {reset_token}") + logger.info(f"Reset link: https://yourapp.com/reset-password?token={reset_token}") return True @@ -358,39 +458,51 @@ async def confirm_password_reset(self, reset_token: str, new_password: str) -> b """ db = self.get_db() - # Find and validate reset token - reset_record = await db.password_resets.find_one({ - "token": reset_token, - "used": False, - "expires_at": {"$gt": datetime.now(timezone.utc)} - }) - - if not reset_record: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid or expired reset token" + try: + # Find and validate reset token + reset_record = await db.password_resets.find_one({ + "token": reset_token, + "used": False, + "expires_at": {"$gt": datetime.now(timezone.utc)} + }) + + if not reset_record: + logger.warning("Invalid or expired reset token") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired reset token" + ) + + # Update user password + new_hash = get_password_hash(new_password) + await db.users.update_one( + {"_id": reset_record["user_id"]}, + {"$set": {"hashed_password": new_hash}} ) - - # Update user password - new_hash = get_password_hash(new_password) - await db.users.update_one( - {"_id": reset_record["user_id"]}, - {"$set": {"hashed_password": new_hash}} - ) - - # Mark token as used - await db.password_resets.update_one( - {"_id": reset_record["_id"]}, - {"$set": {"used": True}} - ) - - # Revoke all refresh tokens for this user (force re-login) - await db.refresh_tokens.update_many( - {"user_id": reset_record["user_id"]}, - {"$set": {"revoked": True}} - ) - - return True + + # Mark token as used + await db.password_resets.update_one( + {"_id": reset_record["_id"]}, + {"$set": {"used": True}} + ) + + # Revoke all refresh tokens for this user (force re-login) + await db.refresh_tokens.update_many( + {"user_id": reset_record["user_id"]}, + {"$set": {"revoked": True}} + ) + logger.info(f"Password reset successful for user_id: {reset_record['user_id']}") + return True + + except HTTPException: + raise # Raising HTTPException to avoid logging again + except Exception as e: + logger.exception(f"Unexpected error during password reset: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error during password reset" + ) + async def _create_refresh_token_record(self, user_id: str) -> str: """ Generates and stores a new refresh token for the specified user. @@ -408,13 +520,20 @@ async def _create_refresh_token_record(self, user_id: str) -> str: refresh_token = create_refresh_token() expires_at = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days) - await db.refresh_tokens.insert_one({ - "token": refresh_token, - "user_id": ObjectId(user_id) if isinstance(user_id, str) else user_id, - "expires_at": expires_at, - "revoked": False, - "created_at": datetime.now(timezone.utc) - }) + try: + await db.refresh_tokens.insert_one({ + "token": refresh_token, + "user_id": ObjectId(user_id) if isinstance(user_id, str) else user_id, + "expires_at": expires_at, + "revoked": False, + "created_at": datetime.now(timezone.utc) + }) + except Exception as e: + logger.error(f"Failed to create refresh token for user_id: {user_id}. Error: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create refresh token: {str(e)}" + ) return refresh_token diff --git a/backend/app/expenses/service.py b/backend/app/expenses/service.py index a55fb665..d7116d78 100644 --- a/backend/app/expenses/service.py +++ b/backend/app/expenses/service.py @@ -1,6 +1,7 @@ +from fastapi import HTTPException, status from typing import List, Dict, Any, Optional, Tuple from datetime import datetime, timedelta -from bson import ObjectId +from bson import ObjectId, errors from app.database import mongodb from app.expenses.schemas import ( ExpenseCreateRequest, ExpenseUpdateRequest, ExpenseResponse, Settlement, @@ -8,6 +9,9 @@ ) import asyncio from collections import defaultdict, deque +import logging + +logger = logging.getLogger(__name__) class ExpenseService: def __init__(self): @@ -35,16 +39,20 @@ async def create_expense(self, group_id: str, expense_data: ExpenseCreateRequest # Validate and convert group_id to ObjectId try: group_obj_id = ObjectId(group_id) - except Exception: - raise ValueError("Group not found or user not a member") + except errors.InvalidId: #Incorrect ObjectId format + logger.warning(f"Invalid group ID format: {group_id}") + raise HTTPException(status_code=400, detail="Invalid group ID") + except Exception as e: + logger.error(f"Unexpected error parsing groupId: {e}") + raise HTTPException(status_code=500, detail="Failed to process group ID") # Verify user is member of the group group = await self.groups_collection.find_one({ "_id": group_obj_id, "members.userId": user_id }) - if not group: - raise ValueError("Group not found or user not a member") + if not group: # User not a member of the group + raise HTTPException(status_code=403, detail="You are not a member of this group") # Create expense document expense_doc = { @@ -199,23 +207,28 @@ async def get_expense_by_id(self, group_id: str, expense_id: str, user_id: str) try: group_obj_id = ObjectId(group_id) expense_obj_id = ObjectId(expense_id) - except Exception: - raise ValueError("Group not found or user not a member") + except errors.InvalidId: #Incorrect ObjectId format for group_id or expense_id + logger.warning(f"Invalid ObjectId(s): group_id={group_id}, expense_id={expense_id}") + raise HTTPException(status_code=400, detail="Invalid group ID or expense ID") + except Exception as e: + logger.error(f"Unexpected error parsing IDs: {e}") + raise HTTPException(status_code=500, detail="Unable to process IDs") + # Verify user access group = await self.groups_collection.find_one({ "_id": group_obj_id, "members.userId": user_id }) - if not group: - raise ValueError("Group not found or user not a member") + if not group: #Unauthorized access + raise HTTPException(status_code=403, detail="You are not a member of this group") expense_doc = await self.expenses_collection.find_one({ "_id": expense_obj_id, "groupId": group_id }) - if not expense_doc: - raise ValueError("Expense not found") + if not expense_doc: #Expense not found + raise HTTPException(status_code=404, detail="Expense not found") expense = await self._expense_doc_to_response(expense_doc) @@ -244,8 +257,9 @@ async def update_expense(self, group_id: str, expense_id: str, updates: ExpenseU # Validate ObjectId format try: expense_obj_id = ObjectId(expense_id) - except Exception as e: - raise ValueError(f"Invalid expense ID format: {expense_id}") + except errors.InvalidId: + logger.warning(f"Invalid expense ID format: {expense_id}") + raise HTTPException(status_code=400, detail="Invalid expense ID format") # Verify user access and that they created the expense expense_doc = await self.expenses_collection.find_one({ @@ -253,21 +267,21 @@ async def update_expense(self, group_id: str, expense_id: str, updates: ExpenseU "groupId": group_id, "createdBy": user_id }) - if not expense_doc: - raise ValueError("Expense not found or not authorized to edit") + if not expense_doc: #Expense not found or user not authorized + raise HTTPException(status_code=403, detail="Not authorized to update this expense or it does not exist") # Validate splits against current or new amount if both are being updated if updates.splits is not None and updates.amount is not None: total_split = sum(split.amount for split in updates.splits) if abs(total_split - updates.amount) > 0.01: - raise ValueError('Split amounts must sum to total expense amount') + raise HTTPException(status_code=400, detail="Split amounts must sum to total expense amount") # If only splits are being updated, validate against current amount elif updates.splits is not None: current_amount = expense_doc["amount"] total_split = sum(split.amount for split in updates.splits) if abs(total_split - current_amount) > 0.01: - raise ValueError('Split amounts must sum to current expense amount') + raise HTTPException(status_code=400, detail="Split amounts must sum to total expense amount") # Store original data for history original_data = { @@ -296,7 +310,8 @@ async def update_expense(self, group_id: str, expense_id: str, updates: ExpenseU try: user = await self.users_collection.find_one({"_id": ObjectId(user_id)}) user_name = user.get("name", "Unknown User") if user else "Unknown User" - except: + except Exception as e: + logger.warning(f"Failed to fetch user for history: {e}") user_name = "Unknown User" history_entry = { @@ -316,8 +331,8 @@ async def update_expense(self, group_id: str, expense_id: str, updates: ExpenseU } ) - if result.matched_count == 0: - raise ValueError("Expense not found during update") + if result.matched_count == 0: #Expense not found during update + raise HTTPException(status_code=404, detail="Expense not found during update") else: # No actual changes, just update the timestamp result = await self.expenses_collection.update_one( @@ -326,7 +341,7 @@ async def update_expense(self, group_id: str, expense_id: str, updates: ExpenseU ) if result.matched_count == 0: - raise ValueError("Expense not found during update") + raise HTTPException(status_code=404, detail="Expense not found during update") # If splits changed, recalculate settlements if updates.splits is not None or updates.amount is not None: @@ -341,20 +356,23 @@ async def update_expense(self, group_id: str, expense_id: str, updates: ExpenseU # Create new settlements await self._create_settlements_for_expense(updated_expense, user_id) except Exception as e: - print(f"Warning: Failed to recalculate settlements: {e}") + logger.warning(f"Warning: Failed to recalculate settlements: {e}") # Logger warning instead of printing # Continue anyway, as the expense update succeeded # Return updated expense updated_expense = await self.expenses_collection.find_one({"_id": expense_obj_id}) if not updated_expense: - raise ValueError("Failed to retrieve updated expense") + raise HTTPException(status_code=500, detail="Failed to retrieve updated expense") return await self._expense_doc_to_response(updated_expense) - except ValueError: + #Allowing FastAPI exception to bubble up for proper handling + except HTTPException: raise - except Exception as e: - print(f"Error in update_expense: {str(e)}") + except ValueError as ve: + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: # logger.exception() will provide the entire traceback, so its safe to remove traceback + logger.exception(f"Unhandled error in update_expense for expense {expense_id}: {e}") import traceback traceback.print_exc() raise Exception(f"Database error during expense update: {str(e)}") @@ -369,7 +387,8 @@ async def delete_expense(self, group_id: str, expense_id: str, user_id: str) -> "createdBy": user_id }) if not expense_doc: - raise ValueError("Expense not found or not authorized to delete") + logger.warning(f"Unauthorized delete attempt or missing expense: {expense_id} by user {user_id}") + raise HTTPException(status_code=403, detail="Not authorized to delete this expense or it does not exist") # Delete settlements for this expense await self.settlements_collection.delete_many({"expenseId": expense_id}) @@ -521,7 +540,8 @@ async def create_manual_settlement(self, group_id: str, settlement_data: Settlem "members.userId": user_id }) if not group: - raise ValueError("Group not found or user not a member") + logger.warning(f"Unauthorized access attempt to group {group_id} by user {user_id}") + raise HTTPException(status_code=403, detail="Group not found or user not a member") # Get user names users = await self.users_collection.find({ @@ -592,7 +612,8 @@ async def get_group_settlements(self, group_id: str, user_id: str, status_filter "members.userId": user_id }) if not group: - raise ValueError("Group not found or user not a member") + logger.warning(f"Unauthorized access attempt to group {group_id} by user {user_id}") + raise HTTPException(status_code=403, detail="Group not found or user not a member") # Build query query = {"groupId": group_id} @@ -626,11 +647,11 @@ async def get_settlement_by_id(self, group_id: str, settlement_id: str, user_id: # Verify user access group = await self.groups_collection.find_one({ - "_id": ObjectId(group_id), + "_id": ObjectId(group_id), #Assuming valid object ID format (same as above functions) "members.userId": user_id }) if not group: - raise ValueError("Group not found or user not a member") + raise HTTPException(status_code=403, detail="Group not found or user not a member") settlement_doc = await self.settlements_collection.find_one({ "_id": ObjectId(settlement_id), @@ -638,7 +659,7 @@ async def get_settlement_by_id(self, group_id: str, settlement_id: str, user_id: }) if not settlement_doc: - raise ValueError("Settlement not found") + raise HTTPException(status_code=404, detail="Settlement not found") return Settlement(**{ **settlement_doc, @@ -663,7 +684,7 @@ async def update_settlement_status(self, group_id: str, settlement_id: str, stat ) if result.matched_count == 0: - raise ValueError("Settlement not found") + raise HTTPException(status_code=404, detail="Settlement not found") # Get updated settlement settlement_doc = await self.settlements_collection.find_one({"_id": ObjectId(settlement_id)}) @@ -682,7 +703,7 @@ async def delete_settlement(self, group_id: str, settlement_id: str, user_id: st "members.userId": user_id }) if not group: - raise ValueError("Group not found or user not a member") + raise HTTPException(status_code=403, detail="Group not found or user not a member") result = await self.settlements_collection.delete_one({ "_id": ObjectId(settlement_id), @@ -700,7 +721,7 @@ async def get_user_balance_in_group(self, group_id: str, target_user_id: str, cu "members.userId": current_user_id }) if not group: - raise ValueError("Group not found or user not a member") + raise HTTPException(status_code=403, detail="Group not found or user not a member") # Get user info user = await self.users_collection.find_one({"_id": ObjectId(target_user_id)}) @@ -998,7 +1019,7 @@ async def get_group_analytics(self, group_id: str, user_id: str, period: str = " "members.userId": user_id }) if not group: - raise ValueError("Group not found or user not a member") + raise HTTPException(status_code=403, detail="Group not found or user not a member") # Build date range if period == "month" and year and month: diff --git a/backend/app/groups/service.py b/backend/app/groups/service.py index ad920fea..c047f3a2 100644 --- a/backend/app/groups/service.py +++ b/backend/app/groups/service.py @@ -1,10 +1,13 @@ from fastapi import HTTPException, status from app.database import get_database -from bson import ObjectId +from bson import ObjectId, errors from datetime import datetime, timezone from typing import Optional, Dict, Any, List import secrets import string +import logging + +logger = logging.getLogger(__name__) class GroupService: def __init__(self): @@ -47,7 +50,20 @@ async def _enrich_members_with_user_details(self, members: List[dict]) -> List[d } } enriched_members.append(enriched_member) + except errors.InvalidId: #exception for invalid ObjectId + logger.warning(f"Invalid ObjectId for userId: {member_user_id}") + enriched_members.append({ + "userId": member_user_id, + "role": member.get("role", "member"), + "joinedAt": member.get("joinedAt"), + "user": { + "name": f"User {member_user_id[-4:]}", + "email": f"{member_user_id}@example.com", + "avatar": None + } + }) except Exception as e: + logger.error(f"Error enriching userId {member_user_id}: {e}") # If user lookup fails, add member with basic info enriched_members.append({ "userId": member_user_id, @@ -71,7 +87,8 @@ def transform_group_document(self, group: dict) -> dict: return None try: group_id = str(group["_id"]) - except Exception: + except Exception as e: + logger.warning(f"Failed to get _id from group document: {e}") return None return { @@ -135,7 +152,11 @@ async def get_group_by_id(self, group_id: str, user_id: str) -> Optional[dict]: db = self.get_db() try: obj_id = ObjectId(group_id) - except Exception: + except errors.InvalidId: + logger.warning(f"Invalid group_id: {group_id}") + return None + except Exception as e: + logger.error(f"Unexpected error converting group_id to ObjectId: {e}") return None group = await db.groups.find_one({ @@ -161,7 +182,11 @@ async def update_group(self, group_id: str, updates: dict, user_id: str) -> Opti db = self.get_db() try: obj_id = ObjectId(group_id) - except Exception: + except errors.InvalidId: + logger.warning(f"Invalid group_id: {group_id}") + return None + except Exception as e: + logger.error(f"Unexpected error converting group_id to ObjectId: {e}") return None # Check if user is admin @@ -184,7 +209,11 @@ async def delete_group(self, group_id: str, user_id: str) -> bool: db = self.get_db() try: obj_id = ObjectId(group_id) - except Exception: + except errors.InvalidId: + logger.warning(f"Invalid group_id: {group_id}") + return False + except Exception as e: + logger.error(f"Unexpected error converting group_id to ObjectId: {e}") return False # Check if user is admin diff --git a/backend/app/user/service.py b/backend/app/user/service.py index 2b81fa4d..191b045e 100644 --- a/backend/app/user/service.py +++ b/backend/app/user/service.py @@ -1,8 +1,11 @@ from fastapi import HTTPException, status, Depends from app.database import get_database -from bson import ObjectId +from bson import ObjectId, errors from datetime import datetime, timezone from typing import Optional, Dict, Any +import logging + +logger = logging.getLogger(__name__) class UserService: def __init__(self): @@ -25,11 +28,13 @@ def iso(dt): dt_utc = dt.astimezone(timezone.utc) if getattr(dt, 'tzinfo', None) else dt.replace(tzinfo=timezone.utc) return dt_utc.isoformat().replace("+00:00", "Z") except AttributeError: + logger.warning("DateTime conversion failed, returning raw string") # Logging failed datetime transformation return str(dt) try: user_id = str(user["_id"]) - except Exception: + except (KeyError, TypeError) as e: + logger.error(f"Invalid user document format: {e}") return None # Handle invalid ObjectId gracefully return { "id": user_id, @@ -45,7 +50,8 @@ async def get_user_by_id(self, user_id: str) -> Optional[dict]: db = self.get_db() try: obj_id = ObjectId(user_id) - except Exception: + except errors.InvalidId as e: + logger.warning(f"Invalid User ID format: {e}") #Invalid ObjectId format return None # Handle invalid ObjectId gracefully user = await db.users.find_one({"_id": obj_id}) return self.transform_user_document(user) @@ -54,7 +60,8 @@ async def update_user_profile(self, user_id: str, updates: dict) -> Optional[dic db = self.get_db() try: obj_id = ObjectId(user_id) - except Exception: + except errors.InvalidId as e: + logger.warning(f"Invalid User ID format: {e}") #Invalid ObjectId format for profile update return None # Handle invalid ObjectId gracefully # Only allow certain fields allowed = {"name", "imageUrl", "currency"} @@ -71,7 +78,8 @@ async def delete_user(self, user_id: str) -> bool: db = self.get_db() try: obj_id = ObjectId(user_id) - except Exception: + except errors.InvalidId as e: + logger.warning(f"Invalid User ID format: {e}") #Invalid ObjectId format for deletion return False # Handle invalid ObjectId gracefully result = await db.users.delete_one({"_id": obj_id}) return result.deleted_count > 0 diff --git a/backend/tests/auth/test_auth_service.py b/backend/tests/auth/test_auth_service.py new file mode 100644 index 00000000..6d4d886d --- /dev/null +++ b/backend/tests/auth/test_auth_service.py @@ -0,0 +1,660 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi import HTTPException, status +from bson import ObjectId +from jose import JWTError +from firebase_admin import auth as firebase_auth +from datetime import datetime, timedelta, timezone +from app.auth.service import AuthService +from app.auth.security import get_password_hash, create_refresh_token, verify_password +from bson import ObjectId +from bson.errors import InvalidId +from pymongo.errors import PyMongoError +import logging + +def validate_object_id(id_str: str, field_name: str = "ID") -> ObjectId: + try: + return ObjectId(id_str) + except InvalidId: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid {field_name}" + ) + +async def test_create_user_with_email_success(monkeypatch): + service = AuthService() + + # Mock DB behavior + mock_db = AsyncMock() + mock_db.users.find_one.return_value = None # No existing user + mock_insert_result = AsyncMock(inserted_id=ObjectId()) + mock_db.users.insert_one.return_value = mock_insert_result + + monkeypatch.setattr(service, "get_db", lambda: mock_db) + monkeypatch.setattr(service, "_create_refresh_token_record", AsyncMock(return_value="mock_refresh_token")) + monkeypatch.setattr("app.auth.service.get_password_hash", lambda pwd: f"hashed_{pwd}") + + result = await service.create_user_with_email("new@example.com", "securepass", "Test User") + + assert result["user"]["email"] == "new@example.com" + assert result["user"]["hashed_password"] == "hashed_securepass" + assert result["refresh_token"] == "mock_refresh_token" + +@pytest.mark.asyncio +async def test_create_user_with_email_already_exists(monkeypatch): + service = AuthService() + + mock_db = AsyncMock() + mock_db.users.find_one.return_value = {"email": "existing@example.com"} + + monkeypatch.setattr(service, "get_db", lambda: mock_db) + + with pytest.raises(HTTPException) as exc: + await service.create_user_with_email("existing@example.com", "pass", "User") + + assert exc.value.status_code == 400 + assert exc.value.detail == "User with this email already exists" + +@pytest.mark.asyncio +async def test_create_user_with_email_refresh_token_error(monkeypatch): + service = AuthService() + + mock_db = AsyncMock() + mock_db.users.find_one.return_value = None + mock_insert_result = AsyncMock(inserted_id=ObjectId()) + mock_db.users.insert_one.return_value = mock_insert_result + + monkeypatch.setattr(service, "get_db", lambda: mock_db) + monkeypatch.setattr("app.auth.service.get_password_hash", lambda pwd: f"hashed_{pwd}") + + async def fail_refresh_token(*args, **kwargs): + raise Exception("Token generation failed") + + monkeypatch.setattr(service, "_create_refresh_token_record", fail_refresh_token) + + with pytest.raises(HTTPException) as exc: + await service.create_user_with_email("fail@example.com", "pass", "User") + + assert exc.value.status_code == 500 + assert exc.value.detail == "Internal server error" + +from pymongo.errors import DuplicateKeyError + +@pytest.mark.asyncio +async def test_create_user_with_email_duplicate_key(monkeypatch): + service = AuthService() + + mock_db = AsyncMock() + mock_db.users.find_one.return_value = None + mock_db.users.insert_one.side_effect = DuplicateKeyError("dup key") + + monkeypatch.setattr(service, "get_db", lambda: mock_db) + monkeypatch.setattr("app.auth.service.get_password_hash", lambda pwd: f"hashed_{pwd}") + monkeypatch.setattr(service, "_create_refresh_token_record", AsyncMock()) + + with pytest.raises(HTTPException) as exc: + await service.create_user_with_email("race@example.com", "pass", "User") + + assert exc.value.status_code == 400 + assert exc.value.detail == "User with this email already exists" + +@pytest.mark.asyncio +async def test_authenticate_user_success(monkeypatch): + service = AuthService() + mock_user = { + "_id": ObjectId(), + "email": "test@example.com", + "hashed_password": "mocked_hash" + } + + mock_db = AsyncMock() + mock_db.users.find_one.return_value = mock_user + + monkeypatch.setattr(service, "get_db", lambda: mock_db) + monkeypatch.setattr("app.auth.service.verify_password", lambda pwd, hash: pwd == "correct-password") + monkeypatch.setattr(service, "_create_refresh_token_record", AsyncMock(return_value="refresh-token")) + + result = await service.authenticate_user_with_email("test@example.com", "correct-password") + + assert result.get("user") == mock_user + assert result.get("refresh_token") == "refresh-token" + +@pytest.mark.asyncio +async def test_authenticate_user_db_error(monkeypatch): + service = AuthService() + mock_db = AsyncMock() + mock_db.users.find_one.side_effect = PyMongoError("DB failure") + + monkeypatch.setattr(service, "get_db", lambda: mock_db) + + with pytest.raises(HTTPException) as e: + await service.authenticate_user_with_email("email", "pass") + + assert e.value.status_code == 500 + assert "Internal server error" in e.value.detail + + +@pytest.mark.asyncio +async def test_authenticate_user_not_found(monkeypatch): + service = AuthService() + mock_db = AsyncMock() + mock_db.users.find_one.return_value = None + + monkeypatch.setattr(service, "get_db", lambda: mock_db) + + with pytest.raises(HTTPException) as e: + await service.authenticate_user_with_email("email", "pass") + + assert e.value.status_code == 401 + assert "Incorrect email or password" in e.value.detail + + +@pytest.mark.asyncio +async def test_authenticate_user_password_incorrect(monkeypatch): + service = AuthService() + mock_user = {"_id": ObjectId(), "email": "test@example.com", "hashed_password": "hashed"} + + mock_db = AsyncMock() + mock_db.users.find_one.return_value = mock_user + + monkeypatch.setattr(service, "get_db", lambda: mock_db) + monkeypatch.setattr("app.auth.service.verify_password", lambda pwd, hash: False) + + with pytest.raises(HTTPException) as e: + await service.authenticate_user_with_email("email", "wrongpass") + + assert e.value.status_code == 401 + assert "Incorrect email or password" in e.value.detail + + +@pytest.mark.asyncio +async def test_authenticate_user_missing_hashed_password(monkeypatch): + service = AuthService() + mock_user = {"_id": ObjectId(), "email": "test@example.com"} # no hashed_password + + mock_db = AsyncMock() + mock_db.users.find_one.return_value = mock_user + + monkeypatch.setattr(service, "get_db", lambda: mock_db) + monkeypatch.setattr("app.auth.service.verify_password", lambda pwd, hash: False) + + with pytest.raises(HTTPException) as e: + await service.authenticate_user_with_email("email", "pass") + + assert e.value.status_code == 401 + +@pytest.mark.asyncio +async def test_authenticate_user_refresh_token_error(monkeypatch): + service = AuthService() + mock_user = { + "_id": ObjectId(), + "email": "test@example.com", + "hashed_password": "mocked_hash" + } + + mock_db = AsyncMock() + mock_db.users.find_one.return_value = mock_user + + monkeypatch.setattr(service, "get_db", lambda: mock_db) + monkeypatch.setattr("app.auth.service.verify_password", lambda pwd, hash: True) + monkeypatch.setattr(service, "_create_refresh_token_record", AsyncMock(side_effect=Exception("fail"))) + + with pytest.raises(HTTPException) as e: + await service.authenticate_user_with_email("email", "pass") + + assert e.value.status_code == 500 + assert "Failed to generate refresh token" in e.value.detail + +@pytest.mark.asyncio +async def test_authenticate_with_google_success(mocker): + mock_token = "valid-id-token" + mock_user_id = ObjectId() + decoded_token = { + "uid": "firebase-uid-123", + "email": "test@example.com", + "name": "Test User", + "picture": "http://example.com/avatar.jpg" + } + + # Mock firebase_auth.verify_id_token + mocker.patch("app.auth.service.firebase_auth.verify_id_token", return_value=decoded_token) + + # Mock db + mock_db = AsyncMock() + mock_db.users.find_one.return_value = None # Simulate new user + mock_db.users.insert_one.return_value.inserted_id = mock_user_id + + mocker.patch.object(AuthService, "get_db", return_value=mock_db) + mocker.patch.object(AuthService, "_create_refresh_token_record", return_value="new_refresh_token") + + service = AuthService() + result = await service.authenticate_with_google(mock_token) + + assert result["user"]["email"] == "test@example.com" + assert result["refresh_token"] == "new_refresh_token" + +@pytest.mark.asyncio +async def test_authenticate_with_google_invalid_token(mocker): + mocker.patch("app.auth.service.firebase_auth.verify_id_token", side_effect=firebase_auth.InvalidIdTokenError("bad token")) + + service = AuthService() + with pytest.raises(HTTPException) as exc_info: + await service.authenticate_with_google("invalid-token") + + assert exc_info.value.status_code == 401 + assert "Invalid Google ID token" in str(exc_info.value.detail) + +@pytest.mark.asyncio +async def test_authenticate_with_google_missing_email(mocker): + + decoded_token = {"uid": "uid123"} # no email + + mocker.patch("app.auth.service.firebase_auth.verify_id_token", return_value=decoded_token) + + service = AuthService() + with pytest.raises(HTTPException) as exc_info: + await service.authenticate_with_google("any") + + assert exc_info.value.status_code == 400 + assert "Email not provided" in str(exc_info.value.detail) + +@pytest.mark.asyncio +async def test_authenticate_with_google_db_find_error(mocker): + decoded_token = { + "uid": "uid123", + "email": "test@example.com" + } + + mocker.patch("app.auth.service.firebase_auth.verify_id_token", return_value=decoded_token) + + mock_db = AsyncMock() + mock_db.users.find_one.side_effect = PyMongoError("db error") + mocker.patch.object(AuthService, "get_db", return_value=mock_db) + + service = AuthService() + with pytest.raises(HTTPException) as exc_info: + await service.authenticate_with_google("any") + + assert exc_info.value.status_code == 500 + +@pytest.mark.asyncio +async def test_authenticate_with_google_insert_error(mocker): + decoded_token = { + "uid": "uid123", + "email": "test@example.com", + } + + mocker.patch("app.auth.service.firebase_auth.verify_id_token", return_value=decoded_token) + + mock_db = AsyncMock() + mock_db.users.find_one.return_value = None + mock_db.users.insert_one.side_effect = PyMongoError("insert failed") + mocker.patch.object(AuthService, "get_db", return_value=mock_db) + + service = AuthService() + with pytest.raises(HTTPException) as exc_info: + await service.authenticate_with_google("token") + + assert exc_info.value.status_code == 500 + assert "Failed to create user" in str(exc_info.value.detail) + +@pytest.mark.asyncio +async def test_refresh_access_token_success(): + service = AuthService() + mock_db = MagicMock() + service.get_db = MagicMock(return_value=mock_db) + + now = datetime.now(timezone.utc) + mock_token_record = { + "token": "valid_refresh_token", + "revoked": False, + "expires_at": now + timedelta(hours=1), + "user_id": "user123", + "_id": "token_id" + } + mock_user = {"_id": "user123", "email": "test@example.com"} + + mock_db.refresh_tokens.find_one = AsyncMock(return_value=mock_token_record) + mock_db.users.find_one = AsyncMock(return_value=mock_user) + mock_db.refresh_tokens.update_one = AsyncMock() + + service._create_refresh_token_record = AsyncMock(return_value="new_refresh_token") + + token = await service.refresh_access_token("valid_refresh_token") + assert token == "new_refresh_token" + mock_db.refresh_tokens.update_one.assert_called_once() + +@pytest.mark.asyncio +async def test_refresh_access_token_invalid_or_expired(): + service = AuthService() + service.get_db = MagicMock(return_value=MagicMock()) + service.get_db().refresh_tokens.find_one = AsyncMock(return_value=None) + + with pytest.raises(HTTPException) as e: + await service.refresh_access_token("invalid_or_expired_token") + + assert e.value.status_code == status.HTTP_401_UNAUTHORIZED + assert "Invalid or expired" in e.value.detail + +@pytest.mark.asyncio +async def test_refresh_access_token_user_not_found(): + service = AuthService() + mock_db = MagicMock() + service.get_db = MagicMock(return_value=mock_db) + + mock_token_record = { + "token": "valid_token", + "revoked": False, + "expires_at": datetime.now(timezone.utc) + timedelta(hours=1), + "user_id": "user123" + } + + mock_db.refresh_tokens.find_one = AsyncMock(return_value=mock_token_record) + mock_db.users.find_one = AsyncMock(return_value=None) + + with pytest.raises(HTTPException) as e: + await service.refresh_access_token("valid_token") + + assert e.value.status_code == status.HTTP_401_UNAUTHORIZED + assert "User not found" in e.value.detail + +@pytest.mark.asyncio +async def test_refresh_access_token_db_failure_on_token(): + service = AuthService() + mock_db = MagicMock() + service.get_db = MagicMock(return_value=mock_db) + mock_db.refresh_tokens.find_one = AsyncMock(side_effect=PyMongoError("DB error")) + + with pytest.raises(HTTPException) as e: + await service.refresh_access_token("any_token") + + assert e.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + +@pytest.mark.asyncio +async def test_refresh_access_token_db_failure_on_user_fetch(): + service = AuthService() + mock_db = MagicMock() + service.get_db = MagicMock(return_value=mock_db) + + mock_token_record = { + "token": "token", + "revoked": False, + "expires_at": datetime.now(timezone.utc) + timedelta(hours=1), + "user_id": "user123" + } + + mock_db.refresh_tokens.find_one = AsyncMock(return_value=mock_token_record) + mock_db.users.find_one = AsyncMock(side_effect=PyMongoError("DB error")) + + with pytest.raises(HTTPException) as e: + await service.refresh_access_token("token") + + assert e.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + +@pytest.mark.asyncio +async def test_refresh_access_token_generation_failure(): + service = AuthService() + mock_db = MagicMock() + service.get_db = MagicMock(return_value=mock_db) + + mock_token_record = { + "token": "token", + "revoked": False, + "expires_at": datetime.now(timezone.utc) + timedelta(hours=1), + "user_id": "user123", + "_id": "token_id" + } + + mock_user = {"_id": "user123"} + mock_db.refresh_tokens.find_one = AsyncMock(return_value=mock_token_record) + mock_db.users.find_one = AsyncMock(return_value=mock_user) + + service._create_refresh_token_record = AsyncMock(side_effect=Exception("Token gen fail")) + + with pytest.raises(HTTPException) as e: + await service.refresh_access_token("token") + + assert e.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert "Failed to create refresh token" in e.value.detail + +@pytest.mark.asyncio +async def test_verify_access_token_valid(monkeypatch): + service = AuthService() + + # Mock verify_token to return a payload + monkeypatch.setattr("app.auth.security.verify_token", lambda token: {"sub": "user123"}) + + # Mock DB response + mock_user = {"_id": "user123", "email": "test@example.com"} + mock_db = AsyncMock() + mock_db.users.find_one.return_value = mock_user + monkeypatch.setattr(service, "get_db", lambda: mock_db) + + user = await service.verify_access_token("validtoken") + assert user == mock_user + +@pytest.mark.asyncio +async def test_verify_access_token_invalid_token(monkeypatch): + service = AuthService() + + def raise_jwt_error(token): + raise JWTError("Invalid signature") + + monkeypatch.setattr("app.auth.security.verify_token", raise_jwt_error) + + with pytest.raises(HTTPException) as exc_info: + await service.verify_access_token("badtoken") + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Invalid token" + +@pytest.mark.asyncio +async def test_verify_access_token_missing_sub(monkeypatch): + service = AuthService() + + monkeypatch.setattr("app.auth.security.verify_token", lambda token: {}) + + with pytest.raises(HTTPException) as exc_info: + await service.verify_access_token("token") + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Invalid token" + +@pytest.mark.asyncio +async def test_verify_access_token_db_error(monkeypatch): + service = AuthService() + + monkeypatch.setattr("app.auth.security.verify_token", lambda token: {"sub": "user123"}) + + mock_db = AsyncMock() + mock_db.users.find_one.side_effect = Exception("DB failure") + monkeypatch.setattr(service, "get_db", lambda: mock_db) + + with pytest.raises(HTTPException) as exc_info: + await service.verify_access_token("token") + + assert exc_info.value.status_code == 500 + assert exc_info.value.detail == "Internal server error" + +@pytest.mark.asyncio +async def test_verify_access_token_user_not_found(monkeypatch): + service = AuthService() + + monkeypatch.setattr("app.auth.security.verify_token", lambda token: {"sub": "user123"}) + + mock_db = AsyncMock() + mock_db.users.find_one.return_value = None + monkeypatch.setattr(service, "get_db", lambda: mock_db) + + with pytest.raises(HTTPException) as exc_info: + await service.verify_access_token("token") + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "User not found" + +@pytest.mark.asyncio +async def test_request_password_reset_user_exists(monkeypatch, caplog): + service = AuthService() + mock_db = AsyncMock() + mock_user = {"_id": "mock_user_id", "email": "test@example.com"} + + mock_db.users.find_one.return_value = mock_user + mock_db.password_resets.insert_one.return_value = None + + monkeypatch.setattr(service, "get_db", lambda: mock_db) #temporarily override get_db in function scope + + # Mock the token generator + with patch("app.auth.service.generate_reset_token", return_value="mocktoken"): + with caplog.at_level(logging.INFO): #caplog captures the log messages (previously print statements) + result = await service.request_password_reset("test@example.com") + + # Assert + assert result is True + assert "mocktoken" in caplog.text + assert "Reset link" in caplog.text + mock_db.users.find_one.assert_awaited_once_with({"email": "test@example.com"}) + mock_db.password_resets.insert_one.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_request_password_reset_user_does_not_exist(monkeypatch): + service = AuthService() + mock_db = AsyncMock() + mock_db.users.find_one.return_value = None # No user found + + monkeypatch.setattr(service, "get_db", lambda: mock_db) + + result = await service.request_password_reset("nonexistent@example.com") + + assert result is True + mock_db.users.find_one.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_request_password_reset_db_error_on_lookup(monkeypatch): + service = AuthService() + mock_db = AsyncMock() + mock_db.users.find_one.side_effect = PyMongoError("DB failure") + + monkeypatch.setattr(service, "get_db", lambda: mock_db) + + with pytest.raises(HTTPException) as exc_info: + await service.request_password_reset("test@example.com") + + assert exc_info.value.status_code == 500 + assert "user lookup" in exc_info.value.detail.lower() + + +@pytest.mark.asyncio +async def test_request_password_reset_db_error_on_insert(monkeypatch): + service = AuthService() + mock_db = AsyncMock() + mock_user = {"_id": "mock_user_id", "email": "test@example.com"} + mock_db.users.find_one.return_value = mock_user + mock_db.password_resets.insert_one.side_effect = PyMongoError("Insert failure") + + monkeypatch.setattr(service, "get_db", lambda: mock_db) + + with patch("app.auth.service.generate_reset_token", return_value="mocktoken"): + with pytest.raises(HTTPException) as exc_info: + await service.request_password_reset("test@example.com") + + assert exc_info.value.status_code == 500 + assert "token storage" in exc_info.value.detail.lower() + +@pytest.mark.asyncio +async def test_confirm_password_reset_success(): + service = AuthService() + mock_db = MagicMock() + mock_user_id = ObjectId() + + future_time = datetime.now(timezone.utc) + timedelta(hours=1) + + with patch.object(service, 'get_db', return_value=mock_db): + # Mock reset token lookup + mock_db.password_resets.find_one = AsyncMock(return_value={ + "token": "validtoken", + "used": False, + "expires_at": future_time, + "_id": ObjectId(), + "user_id": mock_user_id + }) + + # Mock user update + mock_db.users.update_one = AsyncMock(return_value=MagicMock(modified_count=1)) + mock_db.password_resets.update_one = AsyncMock() + mock_db.refresh_tokens.update_many = AsyncMock() + + result = await service.confirm_password_reset("validtoken", "newpassword123") + assert result is True + + mock_db.users.update_one.assert_awaited_once() + mock_db.refresh_tokens.update_many.assert_awaited_once() + +@pytest.mark.asyncio +async def test_confirm_password_reset_invalid_or_expired_token(): + service = AuthService() + mock_db = MagicMock() + + with patch.object(service, 'get_db', return_value=mock_db): + # Simulate token not found (invalid, used, or expired) + mock_db.password_resets.find_one = AsyncMock(return_value=None) + + with pytest.raises(HTTPException) as exc_info: + await service.confirm_password_reset("badtoken", "irrelevantpassword") + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Invalid or expired reset token" + + # No DB updates should have been made + mock_db.users.update_one.assert_not_called() + mock_db.password_resets.update_one.assert_not_called() + mock_db.refresh_tokens.update_many.assert_not_called() + +@pytest.mark.asyncio +async def test_create_refresh_token_record_success(): + service = AuthService() + mock_db = MagicMock() + mock_token = "mocked_refresh_token" + mock_user_id = str(ObjectId()) + + # Patch get_db and create_refresh_token + with patch.object(service, 'get_db', return_value=mock_db), \ + patch('app.auth.service.create_refresh_token', return_value=mock_token): + + # Mock insert + mock_db.refresh_tokens.insert_one = AsyncMock() + + result = await service._create_refresh_token_record(mock_user_id) + + assert result == mock_token + mock_db.refresh_tokens.insert_one.assert_awaited_once() + +@pytest.mark.asyncio +async def test_create_refresh_token_record_db_failure(): + service = AuthService() + mock_db = MagicMock() + mock_user_id = str(ObjectId()) + + with patch.object(service, 'get_db', return_value=mock_db), \ + patch('app.auth.service.create_refresh_token', return_value="badtoken"): + + # DB failure + mock_db.refresh_tokens.insert_one = AsyncMock(side_effect=Exception("DB write error")) + + with pytest.raises(HTTPException) as exc_info: + await service._create_refresh_token_record(mock_user_id) + + assert exc_info.value.status_code == 500 + assert "Failed to create refresh token" in exc_info.value.detail + +@pytest.mark.asyncio +async def test_create_refresh_token_record_invalid_user_id(): + service = AuthService() + invalid_user_id = "not-a-valid-objectid" + + with pytest.raises(HTTPException) as exc_info: + validate_object_id(invalid_user_id, field_name="user ID") + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Invalid user ID" diff --git a/backend/tests/expenses/test_expense_service.py b/backend/tests/expenses/test_expense_service.py index dc0733ce..8a903508 100644 --- a/backend/tests/expenses/test_expense_service.py +++ b/backend/tests/expenses/test_expense_service.py @@ -1,4 +1,5 @@ import pytest +from fastapi import HTTPException from unittest.mock import AsyncMock, MagicMock, patch from app.expenses.service import ExpenseService from app.expenses.schemas import ExpenseCreateRequest, ExpenseSplit, SplitType @@ -103,13 +104,25 @@ async def test_create_expense_invalid_group(expense_service): mock_mongodb.database = mock_db mock_db.groups.find_one = AsyncMock(return_value=None) - # Test with invalid ObjectId format + '''# Test with invalid ObjectId format with pytest.raises(ValueError, match="Group not found or user not a member"): await expense_service.create_expense("invalid_group", expense_request, "user_a") # Test with valid ObjectId format but non-existent group with pytest.raises(ValueError, match="Group not found or user not a member"): - await expense_service.create_expense("65f1a2b3c4d5e6f7a8b9c0d0", expense_request, "user_a") + await expense_service.create_expense("65f1a2b3c4d5e6f7a8b9c0d0", expense_request, "user_a")''' + # Updated after stricter exception handling (July 2025) + # Case 1: Invalid ObjectId format + with pytest.raises(HTTPException) as exc_info_1: + await expense_service.create_expense("invalid_group", expense_request, "user_a") + assert exc_info_1.value.status_code == 400 + assert "Invalid group ID" in str(exc_info_1.value.detail) + + # Case 2: Valid ObjectId format but group not found or user not a member + with pytest.raises(HTTPException) as exc_info_2: + await expense_service.create_expense(str(ObjectId()), expense_request, "user_a") + assert exc_info_2.value.status_code == 403 + assert "not a member of this group" in str(exc_info_2.value.detail) @pytest.mark.asyncio async def test_calculate_optimized_settlements_advanced(expense_service): @@ -306,13 +319,23 @@ async def test_update_expense_unauthorized(expense_service): # Mock finding no expense (user not creator) mock_db.expenses.find_one = AsyncMock(return_value=None) - with pytest.raises(ValueError, match="Expense not found or not authorized to edit"): + '''with pytest.raises(ValueError, match="Expense not found or not authorized to edit"): + await expense_service.update_expense( + "group_id", + "65f1a2b3c4d5e6f7a8b9c0d1", + update_request, + "unauthorized_user" + )''' + # Updated after stricter exception handling (July 2025) + with pytest.raises(HTTPException) as exc_info: await expense_service.update_expense( "group_id", "65f1a2b3c4d5e6f7a8b9c0d1", update_request, "unauthorized_user" ) + assert exc_info.value.status_code == 403 + assert "Not authorized" in str(exc_info.value.detail) def test_expense_split_validation(): """Test expense split validation with proper assertions""" @@ -425,8 +448,17 @@ async def test_get_expense_by_id_not_found(expense_service): # Mock expense not found mock_db.expenses.find_one = AsyncMock(return_value=None) - with pytest.raises(ValueError, match="Expense not found"): - await expense_service.get_expense_by_id("65f1a2b3c4d5e6f7a8b9c0d0", "65f1a2b3c4d5e6f7a8b9c0d1", "user_a") + ''' with pytest.raises(ValueError, match="Expense not found"): + await expense_service.get_expense_by_id("65f1a2b3c4d5e6f7a8b9c0d0", "65f1a2b3c4d5e6f7a8b9c0d1", "user_a")''' + # Updated after stricter exception handling (July 2025) + with pytest.raises(HTTPException) as exc_info: + await expense_service.get_expense_by_id( + "65f1a2b3c4d5e6f7a8b9c0d0", + "65f1a2b3c4d5e6f7a8b9c0d1", + "user_a" + ) + assert exc_info.value.status_code == 404 + assert "Expense not found" in exc_info.value.detail @pytest.mark.asyncio async def test_list_group_expenses_success(expense_service, mock_group_data, mock_expense_data): @@ -643,8 +675,17 @@ async def test_delete_expense_not_found(expense_service): mock_db.settlements.delete_many = AsyncMock() # Should not be called if expense not found mock_db.expenses.delete_one = AsyncMock() # Should not be called - with pytest.raises(ValueError, match="Expense not found or not authorized to delete"): + '''with pytest.raises(ValueError, match="Expense not found or not authorized to delete"): + await expense_service.delete_expense(group_id, expense_id, user_id) + + mock_db.settlements.delete_many.assert_not_called() + mock_db.expenses.delete_one.assert_not_called()''' + # Updated after stricter exception handling (July 2025) + with pytest.raises(HTTPException) as exc_info: await expense_service.delete_expense(group_id, expense_id, user_id) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Not authorized to delete this expense or it does not exist" mock_db.settlements.delete_many.assert_not_called() mock_db.expenses.delete_one.assert_not_called() @@ -764,9 +805,17 @@ async def test_create_manual_settlement_group_not_found(expense_service): mock_mongodb.database = mock_db mock_db.groups.find_one = AsyncMock(return_value=None) # Group not found - with pytest.raises(ValueError, match="Group not found or user not a member"): + '''with pytest.raises(ValueError, match="Group not found or user not a member"): + await expense_service.create_manual_settlement(group_id, settlement_request, user_id) + + mock_db.settlements.insert_one.assert_not_called()''' + #Updated after stricter exception handling (July 2025) + with pytest.raises(HTTPException) as exc_info: await expense_service.create_manual_settlement(group_id, settlement_request, user_id) + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Group not found or user not a member" + mock_db.settlements.insert_one.assert_not_called() @pytest.mark.asyncio @@ -870,9 +919,15 @@ async def test_get_group_settlements_group_not_found(expense_service): mock_mongodb.database = mock_db mock_db.groups.find_one = AsyncMock(return_value=None) # Group not found - with pytest.raises(ValueError, match="Group not found or user not a member"): + '''with pytest.raises(ValueError, match="Group not found or user not a member"): + await expense_service.get_group_settlements(group_id, user_id)''' + # Updated after stricter exception handling (July 2025) + with pytest.raises(HTTPException) as exc_info: await expense_service.get_group_settlements(group_id, user_id) + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Group not found or user not a member" + mock_db.settlements.find.assert_not_called() mock_db.settlements.count_documents.assert_not_called() @@ -928,9 +983,15 @@ async def test_get_settlement_by_id_not_found(expense_service, mock_group_data): mock_db.groups.find_one = AsyncMock(return_value=mock_group_data) mock_db.settlements.find_one = AsyncMock(return_value=None) # Settlement not found - with pytest.raises(ValueError, match="Settlement not found"): + '''with pytest.raises(ValueError, match="Settlement not found"): + await expense_service.get_settlement_by_id(group_id, settlement_id_str, user_id)''' + # Updated after stricter exception handling (July 2025) + with pytest.raises(HTTPException) as exc_info: await expense_service.get_settlement_by_id(group_id, settlement_id_str, user_id) + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Settlement not found" + @pytest.mark.asyncio async def test_get_settlement_by_id_group_access_denied(expense_service): """Test retrieving settlement when user not member of the group""" @@ -944,9 +1005,15 @@ async def test_get_settlement_by_id_group_access_denied(expense_service): mock_db.groups.find_one = AsyncMock(return_value=None) # User not in group / group doesn't exist - with pytest.raises(ValueError, match="Group not found or user not a member"): + '''with pytest.raises(ValueError, match="Group not found or user not a member"): + await expense_service.get_settlement_by_id(group_id, settlement_id_str, user_id)''' + # Updated after stricter exception handling (July 2025) + with pytest.raises(HTTPException) as exc_info: await expense_service.get_settlement_by_id(group_id, settlement_id_str, user_id) + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Group not found or user not a member" + mock_db.settlements.find_one.assert_not_called() @pytest.mark.asyncio @@ -1023,11 +1090,19 @@ async def test_update_settlement_status_not_found(expense_service): mock_db.settlements.find_one = AsyncMock(return_value=None) - with pytest.raises(ValueError, match="Settlement not found"): + '''with pytest.raises(ValueError, match="Settlement not found"): + await expense_service.update_settlement_status( + group_id, settlement_id_str, new_status + )''' + # Updated after stricter exception handling (July 2025) + with pytest.raises(HTTPException) as exc_info: await expense_service.update_settlement_status( group_id, settlement_id_str, new_status ) + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "Settlement not found" + mock_db.settlements.find_one.assert_not_called() # Should not be called if update fails @pytest.mark.asyncio @@ -1096,9 +1171,15 @@ async def test_delete_settlement_group_access_denied(expense_service): mock_db.groups.find_one = AsyncMock(return_value=None) # User not in group - with pytest.raises(ValueError, match="Group not found or user not a member"): + '''with pytest.raises(ValueError, match="Group not found or user not a member"): + await expense_service.delete_settlement(group_id, settlement_id_str, user_id)''' + # Updated after stricter exception handling (July 2025) + with pytest.raises(HTTPException) as exc_info: await expense_service.delete_settlement(group_id, settlement_id_str, user_id) + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Group not found or user not a member" + mock_db.settlements.delete_one.assert_not_called() @pytest.mark.asyncio @@ -1203,8 +1284,16 @@ async def test_get_user_balance_in_group_access_denied(expense_service): mock_mongodb.database = mock_db mock_db.groups.find_one = AsyncMock(return_value=None) # Current user not member - with pytest.raises(ValueError, match="Group not found or user not a member"): - await expense_service.get_user_balance_in_group(group_id, target_user_id_str, current_user_id) + '''with pytest.raises(ValueError, match="Group not found or user not a member"): + await expense_service.get_user_balance_in_group(group_id, target_user_id_str, current_user_id)''' + # Updated after stricter exception handling (July 2025) + with pytest.raises(HTTPException) as exc_info: + await expense_service.get_user_balance_in_group( + group_id, target_user_id_str, current_user_id + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Group not found or user not a member" mock_db.users.find_one.assert_not_called() mock_db.settlements.aggregate.assert_not_called() @@ -1650,9 +1739,15 @@ async def test_get_group_analytics_group_not_found(expense_service): mock_mongodb.database = mock_db mock_db.groups.find_one = AsyncMock(return_value=None) # Group not found - with pytest.raises(ValueError, match="Group not found or user not a member"): + '''with pytest.raises(ValueError, match="Group not found or user not a member"): + await expense_service.get_group_analytics(group_id, user_id)''' + # Updated after stricter exception handling (July 2025) + with pytest.raises(HTTPException) as exc_info: await expense_service.get_group_analytics(group_id, user_id) + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == "Group not found or user not a member" + mock_db.expenses.find.assert_not_called() mock_db.users.find_one.assert_not_called() diff --git a/backend/tests/groups/test_groups_service.py b/backend/tests/groups/test_groups_service.py index 4d3d1178..93379222 100644 --- a/backend/tests/groups/test_groups_service.py +++ b/backend/tests/groups/test_groups_service.py @@ -365,3 +365,37 @@ async def test_leave_group_allow_member_to_leave(self): result = await self.service.leave_group("642f1e4a9b3c2d1f6a1b2c3d", "user456") assert result is True + + # Adding invalid object ID and partial input tests for modified exception handling + @pytest.mark.asyncio + async def test_get_group_by_id_invalid_objectid(self): + """Test get_group_by_id with invalid ObjectId format""" + with patch.object(self.service, 'get_db'): + result = await self.service.get_group_by_id("invalid-id", "user123") + assert result is None + + @pytest.mark.asyncio + async def test_update_group_invalid_objectid(self): + """Test update_group with invalid ObjectId""" + with patch.object(self.service, 'get_db'): + result = await self.service.update_group("invalid-id", {"name": "test"}, "user123") + assert result is None + + @pytest.mark.asyncio + async def test_delete_group_invalid_objectid(self): + """Test delete_group with invalid ObjectId""" + with patch.object(self.service, 'get_db'): + result = await self.service.delete_group("invalid-id", "user123") + assert result is False + + def test_transform_group_document_partial_input(self): + """Test transform with partial group fields""" + group_doc = { + "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"), + "name": "Partial Group" + } + + result = self.service.transform_group_document(group_doc) + assert result["name"] == "Partial Group" + assert result["currency"] == "USD" # default fallback + assert result["members"] == [] # default fallback diff --git a/backend/tests/user/test_user_service.py b/backend/tests/user/test_user_service.py index ab6ebfb7..dd748e65 100644 --- a/backend/tests/user/test_user_service.py +++ b/backend/tests/user/test_user_service.py @@ -159,6 +159,17 @@ async def test_get_user_by_id_not_found(mock_db_client, mock_get_database): mock_db_client.users.find_one.assert_called_once_with({"_id": TEST_OBJECT_ID}) assert user is None +# Added Test for invalid ObjectId format +@pytest.mark.asyncio +async def test_get_user_by_id_invalid_objectid(mock_db_client, mock_get_database): + invalid_id = "invalid-objectid" + + user = await user_service.get_user_by_id(invalid_id) + + # No DB calls should be made + mock_db_client.users.find_one.assert_not_called() + assert user is None + # --- Tests for update_user_profile --- @pytest.mark.asyncio @@ -207,6 +218,18 @@ async def test_update_user_profile_user_not_found(mock_db_client, mock_get_datab assert kwargs["return_document"] is True assert updated_user is None +# Added Test for invalid ObjectId format for user update +@pytest.mark.asyncio +async def test_update_user_profile_invalid_object_id(mock_db_client, mock_get_database): + invalid_user_id = "invalid_object_id" # Not a 24-char hex string + update_data = {"name": "Test Name"} + + updated_user = await user_service.update_user_profile(invalid_user_id, update_data) + + # Should return None and never hit the DB + mock_db_client.users.find_one_and_update.assert_not_called() + assert updated_user is None + # --- Tests for delete_user --- @pytest.mark.asyncio @@ -230,3 +253,14 @@ async def test_delete_user_not_found(mock_db_client, mock_get_database): mock_db_client.users.delete_one.assert_called_once_with({"_id": TEST_OBJECT_ID}) assert result is False + +# Added Test for invalid ObjectId format for user deletion +@pytest.mark.asyncio +async def test_delete_user_invalid_object_id(mock_db_client, mock_get_database): + invalid_user_id = "invalid_object_id" # Not a valid 24-char hex string + + result = await user_service.delete_user(invalid_user_id) + + # Expected result: False and never hit the DB + mock_db_client.users.delete_one.assert_not_called() + assert result is False From 74bfab4a75b28fd857e9be3d0d0cf32edd5ea602 Mon Sep 17 00:00:00 2001 From: Samudra Date: Sat, 26 Jul 2025 19:13:04 +0530 Subject: [PATCH 2/5] Slight modification by adding centralized logger to user and group service.py --- backend/app/groups/service.py | 3 +-- backend/app/user/service.py | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/backend/app/groups/service.py b/backend/app/groups/service.py index c047f3a2..157c0679 100644 --- a/backend/app/groups/service.py +++ b/backend/app/groups/service.py @@ -1,13 +1,12 @@ from fastapi import HTTPException, status from app.database import get_database +from app.config import logger from bson import ObjectId, errors from datetime import datetime, timezone from typing import Optional, Dict, Any, List import secrets import string -import logging -logger = logging.getLogger(__name__) class GroupService: def __init__(self): diff --git a/backend/app/user/service.py b/backend/app/user/service.py index 191b045e..fca5eeb8 100644 --- a/backend/app/user/service.py +++ b/backend/app/user/service.py @@ -1,11 +1,9 @@ from fastapi import HTTPException, status, Depends from app.database import get_database +from app.config import logger from bson import ObjectId, errors from datetime import datetime, timezone from typing import Optional, Dict, Any -import logging - -logger = logging.getLogger(__name__) class UserService: def __init__(self): From 03613f50b0016d32b7b3d8f543a7bb8ee9223b20 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 08:44:00 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- backend/app/auth/service.py | 214 ++++++++------- backend/app/expenses/service.py | 198 +++++++++----- backend/app/groups/service.py | 66 ++--- backend/app/user/service.py | 22 +- backend/tests/auth/test_auth_service.py | 247 ++++++++++++------ .../tests/expenses/test_expense_service.py | 19 +- backend/tests/groups/test_groups_service.py | 12 +- backend/tests/user/test_user_service.py | 6 + 8 files changed, 488 insertions(+), 296 deletions(-) diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 5d78ebdf..15d588f9 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -1,14 +1,14 @@ import json import os from datetime import datetime, timedelta, timezone -from typing import Optional, Dict, Any -from pymongo.errors import DuplicateKeyError, PyMongoError +from typing import Any, Dict, Optional + from bson import ObjectId -from jose import JWTError from fastapi import HTTPException, status from firebase_admin import auth as firebase_auth from firebase_admin import credentials -from pymongo.errors import DuplicateKeyError +from jose import JWTError +from pymongo.errors import DuplicateKeyError, PyMongoError # Initialize Firebase Admin SDK if not firebase_admin._apps: @@ -132,7 +132,7 @@ async def create_user_with_email( logger.exception("Unexpected error while creating user with email") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" + detail="Internal server error", ) async def authenticate_user_with_email( @@ -153,16 +153,16 @@ async def authenticate_user_with_email( logger.error(f"Database error during user lookup: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" + detail="Internal server error", ) - + if not user or not verify_password(password, user.get("hashed_password", "")): logger.info("Authentication failed due to invalid credentials.") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", ) - + # Create new refresh token try: refresh_token = await self._create_refresh_token_record(str(user["_id"])) @@ -170,12 +170,9 @@ async def authenticate_user_with_email( logger.error(f"Failed to generate refresh token: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to generate refresh token" + detail="Failed to generate refresh token", ) - return { - "user": user, - "refresh_token": refresh_token - } + return {"user": user, "refresh_token": refresh_token} async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: """ @@ -193,17 +190,18 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: # Verify the Firebase ID token try: decoded_token = firebase_auth.verify_id_token(id_token) - except firebase_auth.InvalidIdTokenError: + except firebase_auth.InvalidIdTokenError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid Google ID token" + detail="Invalid Google ID token", ) - firebase_uid = decoded_token['uid'] - email = decoded_token.get('email') - name = decoded_token.get('name', email.split('@')[0] if email else 'User') - picture = decoded_token.get('picture') - + firebase_uid = decoded_token["uid"] + email = decoded_token.get("email") + name = decoded_token.get( + "name", email.split("@")[0] if email else "User") + picture = decoded_token.get("picture") + if not email: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -214,15 +212,14 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: # Check if user exists try: - user = await db.users.find_one({"$or": [ - {"email": email}, - {"firebase_uid": firebase_uid} - ]}) + user = await db.users.find_one( + {"$or": [{"email": email}, {"firebase_uid": firebase_uid}]} + ) except PyMongoError as e: logger.error("Database error while checking user: %s", str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" + detail="Internal server error", ) if user: # Update user info if needed @@ -235,12 +232,12 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: if update_data: try: await db.users.update_one( - {"_id": user["_id"]}, - {"$set": update_data} + {"_id": user["_id"]}, {"$set": update_data} ) user.update(update_data) except PyMongoError as e: - logger.warning("Failed to update user profile: %s", str(e)) + logger.warning( + "Failed to update user profile: %s", str(e)) else: # Create new user user_doc = { @@ -258,28 +255,31 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: user_doc["_id"] = result.inserted_id user = user_doc except PyMongoError as e: - logger.error("Failed to create new Google user: %s", str(e)) + logger.error( + "Failed to create new Google user: %s", str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create user" + detail="Failed to create user", ) - + # Create refresh token try: - refresh_token = await self._create_refresh_token_record(str(user["_id"])) + refresh_token = await self._create_refresh_token_record( + str(user["_id"]) + ) except Exception as e: - logger.error("Failed to issue refresh token for Google login: %s", str(e)) + logger.error( + "Failed to issue refresh token for Google login: %s", str( + e) + ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to generate refresh token" + detail="Failed to generate refresh token", ) - return { - "user": user, - "refresh_token": refresh_token - } + return {"user": user, "refresh_token": refresh_token} except HTTPException: - raise + raise except Exception as e: logger.exception("Unexpected error during Google authentication") raise HTTPException( @@ -303,18 +303,21 @@ async def refresh_access_token(self, refresh_token: str) -> str: # Find and validate refresh token try: - token_record = await db.refresh_tokens.find_one({ - "token": refresh_token, - "revoked": False, - "expires_at": {"$gt": datetime.now(timezone.utc)} - }) + token_record = await db.refresh_tokens.find_one( + { + "token": refresh_token, + "revoked": False, + "expires_at": {"$gt": datetime.now(timezone.utc)}, + } + ) except PyMongoError as e: - logger.error("Database error while validating refresh token: %s", str(e)) + logger.error( + "Database error while validating refresh token: %s", str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" + detail="Internal server error", ) - + if not token_record: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -328,7 +331,7 @@ async def refresh_access_token(self, refresh_token: str) -> str: logger.error("Error while fetching user: %s", str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" + detail="Internal server error", ) if not user: raise HTTPException( @@ -337,26 +340,27 @@ async def refresh_access_token(self, refresh_token: str) -> str: # Create new refresh token (token rotation) try: - new_refresh_token = await self._create_refresh_token_record(str(user["_id"])) + new_refresh_token = await self._create_refresh_token_record( + str(user["_id"]) + ) except Exception as e: logger.error("Failed to create new refresh token: %s", str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create refresh token" + detail="Failed to create refresh token", ) - + # Revoke old token try: await db.refresh_tokens.update_one( - {"_id": token_record["_id"]}, - {"$set": {"revoked": True}} + {"_id": token_record["_id"]}, {"$set": {"revoked": True}} ) except PyMongoError as e: logger.error("Failed to revoke old refresh token: %s", str(e)) # No raise here since new token is safely issued - - return new_refresh_token - + + return new_refresh_token + async def verify_access_token(self, token: str) -> Dict[str, Any]: """ Verifies an access token and retrieves the associated user. @@ -371,17 +375,16 @@ async def verify_access_token(self, token: str) -> Dict[str, Any]: HTTPException: If the token is invalid or the user does not exist. """ from app.auth.security import verify_token - + try: payload = verify_token(token) user_id = payload.get("sub") except JWTError as e: logger.warning("JWT verification failed: %s", str(e)) raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token" + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" ) - + if not user_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" @@ -395,9 +398,9 @@ async def verify_access_token(self, token: str) -> Dict[str, Any]: logger.error("Error while verifying token: %s", str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" + detail="Internal server error", ) - + if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" @@ -412,13 +415,17 @@ async def request_password_reset(self, email: str) -> bool: If the user exists, generates a password reset token with a 1-hour expiration and stores it in the database. The reset token and link are logged for development purposes. Always returns True to avoid revealing whether the email is registered. """ db = self.get_db() - + try: user = await db.users.find_one({"email": email}) except PyMongoError as e: - logger.error(f"Database error while fetching user by email {email}: {str(e)}") - raise HTTPException(status_code=500, detail="Internal server error during user lookup.") - + logger.error( + f"Database error while fetching user by email {email}: {str(e)}" + ) + raise HTTPException( + status_code=500, detail="Internal server error during user lookup." + ) + if not user: # Don't reveal if email exists or not return True @@ -431,17 +438,21 @@ async def request_password_reset(self, email: str) -> bool: try: # Store reset token await db.password_resets.insert_one( - { + { "user_id": user["_id"], "token": reset_token, "expires_at": reset_expires, "used": False, "created_at": datetime.utcnow(), } - ) + ) except PyMongoError as e: - logger.error(f"Database error while storing reset token for user {email}: {str(e)}") - raise HTTPException(status_code=500, detail="Internal server error during token storage.") + logger.error( + f"Database error while storing reset token for user {email}: {str(e)}" + ) + raise HTTPException( + status_code=500, detail="Internal server error during token storage." + ) # For development/free tier: just log the reset token # In production, you would send this via email @@ -469,52 +480,56 @@ async def confirm_password_reset(self, reset_token: str, new_password: str) -> b HTTPException: If the reset token is invalid or expired. """ db = self.get_db() - + try: # Find and validate reset token - reset_record = await db.password_resets.find_one({ - "token": reset_token, - "used": False, - "expires_at": {"$gt": datetime.now(timezone.utc)} - }) - + reset_record = await db.password_resets.find_one( + { + "token": reset_token, + "used": False, + "expires_at": {"$gt": datetime.now(timezone.utc)}, + } + ) + if not reset_record: logger.warning("Invalid or expired reset token") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid or expired reset token" + detail="Invalid or expired reset token", ) - + # Update user password new_hash = get_password_hash(new_password) await db.users.update_one( {"_id": reset_record["user_id"]}, - {"$set": {"hashed_password": new_hash}} + {"$set": {"hashed_password": new_hash}}, ) - + # Mark token as used await db.password_resets.update_one( - {"_id": reset_record["_id"]}, - {"$set": {"used": True}} + {"_id": reset_record["_id"]}, {"$set": {"used": True}} ) - + # Revoke all refresh tokens for this user (force re-login) await db.refresh_tokens.update_many( - {"user_id": reset_record["user_id"]}, - {"$set": {"revoked": True}} + {"user_id": reset_record["user_id"]}, { + "$set": {"revoked": True}} + ) + logger.info( + f"Password reset successful for user_id: {reset_record['user_id']}" ) - logger.info(f"Password reset successful for user_id: {reset_record['user_id']}") - return True + return True except HTTPException: raise # Raising HTTPException to avoid logging again except Exception as e: - logger.exception(f"Unexpected error during password reset: {str(e)}") + logger.exception( + f"Unexpected error during password reset: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error during password reset" - ) - + detail="Internal server error during password reset", + ) + async def _create_refresh_token_record(self, user_id: str) -> str: """ Generates and stores a new refresh token for the specified user. @@ -536,19 +551,24 @@ async def _create_refresh_token_record(self, user_id: str) -> str: try: await db.refresh_tokens.insert_one( - { + { "token": refresh_token, - "user_id": ObjectId(user_id) if isinstance(user_id, str) else user_id, + "user_id": ( + ObjectId(user_id) if isinstance( + user_id, str) else user_id + ), "expires_at": expires_at, "revoked": False, "created_at": datetime.now(timezone.utc), } - ) + ) except Exception as e: - logger.error(f"Failed to create refresh token for user_id: {user_id}. Error: {str(e)}") + logger.error( + f"Failed to create refresh token for user_id: {user_id}. Error: {str(e)}" + ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create refresh token: {str(e)}" + detail=f"Failed to create refresh token: {str(e)}", ) return refresh_token diff --git a/backend/app/expenses/service.py b/backend/app/expenses/service.py index 71ecd9fe..f9a6ca11 100644 --- a/backend/app/expenses/service.py +++ b/backend/app/expenses/service.py @@ -1,8 +1,6 @@ -from fastapi import HTTPException, status -from typing import List, Dict, Any, Optional, Tuple from datetime import datetime, timedelta -from bson import ObjectId -from app.database import mongodb +from typing import Any, Dict, List, Optional, Tuple + from app.config import logger from app.database import mongodb from app.expenses.schemas import ( @@ -16,6 +14,7 @@ SplitType, ) from bson import ObjectId +from fastapi import HTTPException, status class ExpenseService: @@ -46,20 +45,22 @@ async def create_expense( # Validate and convert group_id to ObjectId try: group_obj_id = ObjectId(group_id) - except errors.InvalidId: #Incorrect ObjectId format + except errors.InvalidId: # Incorrect ObjectId format logger.warning(f"Invalid group ID format: {group_id}") raise HTTPException(status_code=400, detail="Invalid group ID") except Exception as e: logger.error(f"Unexpected error parsing groupId: {e}") - raise HTTPException(status_code=500, detail="Failed to process group ID") - + raise HTTPException( + status_code=500, detail="Failed to process group ID") + # Verify user is member of the group - group = await self.groups_collection.find_one({ - "_id": group_obj_id, - "members.userId": user_id - }) - if not group: # User not a member of the group - raise HTTPException(status_code=403, detail="You are not a member of this group") + group = await self.groups_collection.find_one( + {"_id": group_obj_id, "members.userId": user_id} + ) + if not group: # User not a member of the group + raise HTTPException( + status_code=403, detail="You are not a member of this group" + ) # Create expense document expense_doc = { @@ -236,27 +237,31 @@ async def get_expense_by_id( try: group_obj_id = ObjectId(group_id) expense_obj_id = ObjectId(expense_id) - except errors.InvalidId: #Incorrect ObjectId format for group_id or expense_id - logger.warning(f"Invalid ObjectId(s): group_id={group_id}, expense_id={expense_id}") - raise HTTPException(status_code=400, detail="Invalid group ID or expense ID") + except errors.InvalidId: # Incorrect ObjectId format for group_id or expense_id + logger.warning( + f"Invalid ObjectId(s): group_id={group_id}, expense_id={expense_id}" + ) + raise HTTPException( + status_code=400, detail="Invalid group ID or expense ID" + ) except Exception as e: logger.error(f"Unexpected error parsing IDs: {e}") - raise HTTPException(status_code=500, detail="Unable to process IDs") + raise HTTPException( + status_code=500, detail="Unable to process IDs") - # Verify user access - group = await self.groups_collection.find_one({ - "_id": group_obj_id, - "members.userId": user_id - }) - if not group: #Unauthorized access - raise HTTPException(status_code=403, detail="You are not a member of this group") - - expense_doc = await self.expenses_collection.find_one({ - "_id": expense_obj_id, - "groupId": group_id - }) - if not expense_doc: #Expense not found + group = await self.groups_collection.find_one( + {"_id": group_obj_id, "members.userId": user_id} + ) + if not group: # Unauthorized access + raise HTTPException( + status_code=403, detail="You are not a member of this group" + ) + + expense_doc = await self.expenses_collection.find_one( + {"_id": expense_obj_id, "groupId": group_id} + ) + if not expense_doc: # Expense not found raise HTTPException(status_code=404, detail="Expense not found") expense = await self._expense_doc_to_response(expense_doc) @@ -288,29 +293,37 @@ async def update_expense( expense_obj_id = ObjectId(expense_id) except errors.InvalidId: logger.warning(f"Invalid expense ID format: {expense_id}") - raise HTTPException(status_code=400, detail="Invalid expense ID format") - + raise HTTPException( + status_code=400, detail="Invalid expense ID format") + # Verify user access and that they created the expense - expense_doc = await self.expenses_collection.find_one({ - "_id": expense_obj_id, - "groupId": group_id, - "createdBy": user_id - }) - if not expense_doc: #Expense not found or user not authorized - raise HTTPException(status_code=403, detail="Not authorized to update this expense or it does not exist") + expense_doc = await self.expenses_collection.find_one( + {"_id": expense_obj_id, "groupId": group_id, "createdBy": user_id} + ) + if not expense_doc: # Expense not found or user not authorized + raise HTTPException( + status_code=403, + detail="Not authorized to update this expense or it does not exist", + ) # Validate splits against current or new amount if both are being updated if updates.splits is not None and updates.amount is not None: total_split = sum(split.amount for split in updates.splits) if abs(total_split - updates.amount) > 0.01: - raise HTTPException(status_code=400, detail="Split amounts must sum to total expense amount") - + raise HTTPException( + status_code=400, + detail="Split amounts must sum to total expense amount", + ) + # If only splits are being updated, validate against current amount elif updates.splits is not None: current_amount = expense_doc["amount"] total_split = sum(split.amount for split in updates.splits) if abs(total_split - current_amount) > 0.01: - raise HTTPException(status_code=400, detail="Split amounts must sum to total expense amount") + raise HTTPException( + status_code=400, + detail="Split amounts must sum to total expense amount", + ) # Store original data for history original_data = { @@ -338,8 +351,13 @@ async def update_expense( if len(update_doc) > 1: # More than just updatedAt # Get user name try: - user = await self.users_collection.find_one({"_id": ObjectId(user_id)}) - user_name = user.get("name", "Unknown User") if user else "Unknown User" + user = await self.users_collection.find_one( + {"_id": ObjectId(user_id)} + ) + user_name = ( + user.get( + "name", "Unknown User") if user else "Unknown User" + ) except Exception as e: logger.warning(f"Failed to fetch user for history: {e}") user_name = "Unknown User" @@ -357,9 +375,11 @@ async def update_expense( {"_id": expense_obj_id}, {"$set": update_doc, "$push": {"history": history_entry}}, ) - - if result.matched_count == 0: #Expense not found during update - raise HTTPException(status_code=404, detail="Expense not found during update") + + if result.matched_count == 0: # Expense not found during update + raise HTTPException( + status_code=404, detail="Expense not found during update" + ) else: # No actual changes, just update the timestamp result = await self.expenses_collection.update_one( @@ -367,7 +387,9 @@ async def update_expense( ) if result.matched_count == 0: - raise HTTPException(status_code=404, detail="Expense not found during update") + raise HTTPException( + status_code=404, detail="Expense not found during update" + ) # If splits changed, recalculate settlements if updates.splits is not None or updates.amount is not None: @@ -384,9 +406,13 @@ async def update_expense( if updated_expense: # Create new settlements - await self._create_settlements_for_expense(updated_expense, user_id) + await self._create_settlements_for_expense( + updated_expense, user_id + ) except Exception: - logger.error(f"Warning: Failed to recalculate settlements",exc_info=True) + logger.error( + f"Warning: Failed to recalculate settlements", exc_info=True + ) # Continue anyway, as the expense update succeeded # Return updated expense @@ -394,18 +420,25 @@ async def update_expense( {"_id": expense_obj_id} ) if not updated_expense: - raise HTTPException(status_code=500, detail="Failed to retrieve updated expense") - + raise HTTPException( + status_code=500, detail="Failed to retrieve updated expense" + ) + return await self._expense_doc_to_response(updated_expense) - - #Allowing FastAPI exception to bubble up for proper handling + + # Allowing FastAPI exception to bubble up for proper handling except HTTPException: raise except ValueError as ve: raise HTTPException(status_code=400, detail=str(ve)) - except Exception as e: # logger.exception() will provide the entire traceback, so its safe to remove traceback - logger.exception(f"Unhandled error in update_expense for expense {expense_id}: {e}") + except ( + Exception + ) as e: # logger.exception() will provide the entire traceback, so its safe to remove traceback + logger.exception( + f"Unhandled error in update_expense for expense {expense_id}: {e}" + ) import traceback + traceback.print_exc() raise Exception(f"Database error during expense update: {str(e)}") @@ -420,8 +453,13 @@ async def delete_expense( "createdBy": user_id} ) if not expense_doc: - logger.warning(f"Unauthorized delete attempt or missing expense: {expense_id} by user {user_id}") - raise HTTPException(status_code=403, detail="Not authorized to delete this expense or it does not exist") + logger.warning( + f"Unauthorized delete attempt or missing expense: {expense_id} by user {user_id}" + ) + raise HTTPException( + status_code=403, + detail="Not authorized to delete this expense or it does not exist", + ) # Delete settlements for this expense await self.settlements_collection.delete_many({"expenseId": expense_id}) @@ -586,8 +624,12 @@ async def create_manual_settlement( {"_id": ObjectId(group_id), "members.userId": user_id} ) if not group: - logger.warning(f"Unauthorized access attempt to group {group_id} by user {user_id}") - raise HTTPException(status_code=403, detail="Group not found or user not a member") + logger.warning( + f"Unauthorized access attempt to group {group_id} by user {user_id}" + ) + raise HTTPException( + status_code=403, detail="Group not found or user not a member" + ) # Get user names users = await self.users_collection.find( @@ -677,8 +719,12 @@ async def get_group_settlements( {"_id": ObjectId(group_id), "members.userId": user_id} ) if not group: - logger.warning(f"Unauthorized access attempt to group {group_id} by user {user_id}") - raise HTTPException(status_code=403, detail="Group not found or user not a member") + logger.warning( + f"Unauthorized access attempt to group {group_id} by user {user_id}" + ) + raise HTTPException( + status_code=403, detail="Group not found or user not a member" + ) # Build query query = {"groupId": group_id} @@ -716,12 +762,18 @@ async def get_settlement_by_id( """Get a single settlement by ID""" # Verify user access - group = await self.groups_collection.find_one({ - "_id": ObjectId(group_id), #Assuming valid object ID format (same as above functions) - "members.userId": user_id - }) + group = await self.groups_collection.find_one( + { + "_id": ObjectId( + group_id + ), # Assuming valid object ID format (same as above functions) + "members.userId": user_id, + } + ) if not group: - raise HTTPException(status_code=403, detail="Group not found or user not a member") + raise HTTPException( + status_code=403, detail="Group not found or user not a member" + ) settlement_doc = await self.settlements_collection.find_one( {"_id": ObjectId(settlement_id), "groupId": group_id} @@ -772,7 +824,9 @@ async def delete_settlement( {"_id": ObjectId(group_id), "members.userId": user_id} ) if not group: - raise HTTPException(status_code=403, detail="Group not found or user not a member") + raise HTTPException( + status_code=403, detail="Group not found or user not a member" + ) result = await self.settlements_collection.delete_one( {"_id": ObjectId(settlement_id), "groupId": group_id} @@ -790,7 +844,9 @@ async def get_user_balance_in_group( {"_id": ObjectId(group_id), "members.userId": current_user_id} ) if not group: - raise HTTPException(status_code=403, detail="Group not found or user not a member") + raise HTTPException( + status_code=403, detail="Group not found or user not a member" + ) # Get user info user = await self.users_collection.find_one({"_id": ObjectId(target_user_id)}) @@ -1122,7 +1178,9 @@ async def get_group_analytics( {"_id": ObjectId(group_id), "members.userId": user_id} ) if not group: - raise HTTPException(status_code=403, detail="Group not found or user not a member") + raise HTTPException( + status_code=403, detail="Group not found or user not a member" + ) # Build date range if period == "month" and year and month: diff --git a/backend/app/groups/service.py b/backend/app/groups/service.py index a6c437fd..90ad5036 100644 --- a/backend/app/groups/service.py +++ b/backend/app/groups/service.py @@ -3,13 +3,9 @@ from datetime import datetime, timezone from typing import Any, Dict, List, Optional -from app.database import get_database from app.config import logger +from app.database import get_database from bson import ObjectId, errors -from datetime import datetime, timezone -from typing import Optional, Dict, Any, List -import secrets -import string class GroupService: @@ -73,33 +69,40 @@ async def _enrich_members_with_user_details( ), } enriched_members.append(enriched_member) - except errors.InvalidId: #exception for invalid ObjectId - logger.warning(f"Invalid ObjectId for userId: {member_user_id}") - enriched_members.append({ - "userId": member_user_id, - "role": member.get("role", "member"), - "joinedAt": member.get("joinedAt"), - "user": { - "name": f"User {member_user_id[-4:]}", - "email": f"{member_user_id}@example.com", - "avatar": None + except errors.InvalidId: # exception for invalid ObjectId + logger.warning( + f"Invalid ObjectId for userId: {member_user_id}") + enriched_members.append( + { + "userId": member_user_id, + "role": member.get("role", "member"), + "joinedAt": member.get("joinedAt"), + "user": { + "name": f"User {member_user_id[-4:]}", + "email": f"{member_user_id}@example.com", + "avatar": None, + }, } - }) + ) except Exception as e: - logger.error(f"Error enriching userId {member_user_id}: {e}") + logger.error( + f"Error enriching userId {member_user_id}: {e}") # If user lookup fails, add member with basic info - enriched_members.append({ - "userId": member_user_id, - "role": member.get("role", "member"), - "joinedAt": member.get("joinedAt"), - "user": { - "name": f"User {member_user_id[-4:]}", - "email": f"{member_user_id}@example.com", - "avatar": None + enriched_members.append( + { + "userId": member_user_id, + "role": member.get("role", "member"), + "joinedAt": member.get("joinedAt"), + "user": { + "name": f"User {member_user_id[-4:]}", + "email": f"{member_user_id}@example.com", + "avatar": None, + }, } - }) + ) except Exception as e: - logger.error(f"Error enriching userId {member_user_id}: {e}") + logger.error( + f"Error enriching userId {member_user_id}: {e}") # If user lookup fails, add member with basic info enriched_members.append( { @@ -192,7 +195,8 @@ async def get_group_by_id(self, group_id: str, user_id: str) -> Optional[dict]: logger.warning(f"Invalid group_id: {group_id}") return None except Exception as e: - logger.error(f"Unexpected error converting group_id to ObjectId: {e}") + logger.error( + f"Unexpected error converting group_id to ObjectId: {e}") return None group = await db.groups.find_one({"_id": obj_id, "members.userId": user_id}) @@ -223,7 +227,8 @@ async def update_group( logger.warning(f"Invalid group_id: {group_id}") return None except Exception as e: - logger.error(f"Unexpected error converting group_id to ObjectId: {e}") + logger.error( + f"Unexpected error converting group_id to ObjectId: {e}") return None # Check if user is admin @@ -252,7 +257,8 @@ async def delete_group(self, group_id: str, user_id: str) -> bool: logger.warning(f"Invalid group_id: {group_id}") return False except Exception as e: - logger.error(f"Unexpected error converting group_id to ObjectId: {e}") + logger.error( + f"Unexpected error converting group_id to ObjectId: {e}") return False # Check if user is admin diff --git a/backend/app/user/service.py b/backend/app/user/service.py index e1dc2d76..e91d8b6d 100644 --- a/backend/app/user/service.py +++ b/backend/app/user/service.py @@ -1,11 +1,10 @@ from datetime import datetime, timezone from typing import Any, Dict, Optional -from app.database import get_database from app.config import logger +from app.database import get_database from bson import ObjectId, errors -from datetime import datetime, timezone -from typing import Optional, Dict, Any + class UserService: def __init__(self): @@ -32,13 +31,15 @@ def iso(dt): ) return dt_utc.isoformat().replace("+00:00", "Z") except AttributeError: - logger.warning("DateTime conversion failed, returning raw string") # Logging failed datetime transformation + logger.warning( + "DateTime conversion failed, returning raw string" + ) # Logging failed datetime transformation return str(dt) try: user_id = str(user["_id"]) except (KeyError, TypeError) as e: - logger.error(f"Invalid user document format: {e}") + logger.error(f"Invalid user document format: {e}") return None # Handle invalid ObjectId gracefully return { "id": user_id, @@ -55,7 +56,8 @@ async def get_user_by_id(self, user_id: str) -> Optional[dict]: try: obj_id = ObjectId(user_id) except errors.InvalidId as e: - logger.warning(f"Invalid User ID format: {e}") #Invalid ObjectId format + # Invalid ObjectId format + logger.warning(f"Invalid User ID format: {e}") return None # Handle invalid ObjectId gracefully user = await db.users.find_one({"_id": obj_id}) return self.transform_user_document(user) @@ -65,7 +67,9 @@ async def update_user_profile(self, user_id: str, updates: dict) -> Optional[dic try: obj_id = ObjectId(user_id) except errors.InvalidId as e: - logger.warning(f"Invalid User ID format: {e}") #Invalid ObjectId format for profile update + logger.warning( + f"Invalid User ID format: {e}" + ) # Invalid ObjectId format for profile update return None # Handle invalid ObjectId gracefully # Only allow certain fields allowed = {"name", "imageUrl", "currency"} @@ -81,7 +85,9 @@ async def delete_user(self, user_id: str) -> bool: try: obj_id = ObjectId(user_id) except errors.InvalidId as e: - logger.warning(f"Invalid User ID format: {e}") #Invalid ObjectId format for deletion + logger.warning( + f"Invalid User ID format: {e}" + ) # Invalid ObjectId format for deletion return False # Handle invalid ObjectId gracefully result = await db.users.delete_one({"_id": obj_id}) return result.deleted_count > 0 diff --git a/backend/tests/auth/test_auth_service.py b/backend/tests/auth/test_auth_service.py index 6d4d886d..4dedcb36 100644 --- a/backend/tests/auth/test_auth_service.py +++ b/backend/tests/auth/test_auth_service.py @@ -1,26 +1,28 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -from fastapi import HTTPException, status -from bson import ObjectId -from jose import JWTError -from firebase_admin import auth as firebase_auth +from pymongo.errors import DuplicateKeyError +import logging from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from app.auth.security import create_refresh_token, get_password_hash, verify_password from app.auth.service import AuthService -from app.auth.security import get_password_hash, create_refresh_token, verify_password from bson import ObjectId from bson.errors import InvalidId +from fastapi import HTTPException, status +from firebase_admin import auth as firebase_auth +from jose import JWTError from pymongo.errors import PyMongoError -import logging + def validate_object_id(id_str: str, field_name: str = "ID") -> ObjectId: try: return ObjectId(id_str) except InvalidId: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid {field_name}" + status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid {field_name}" ) + async def test_create_user_with_email_success(monkeypatch): service = AuthService() @@ -31,15 +33,24 @@ async def test_create_user_with_email_success(monkeypatch): mock_db.users.insert_one.return_value = mock_insert_result monkeypatch.setattr(service, "get_db", lambda: mock_db) - monkeypatch.setattr(service, "_create_refresh_token_record", AsyncMock(return_value="mock_refresh_token")) - monkeypatch.setattr("app.auth.service.get_password_hash", lambda pwd: f"hashed_{pwd}") - - result = await service.create_user_with_email("new@example.com", "securepass", "Test User") + monkeypatch.setattr( + service, + "_create_refresh_token_record", + AsyncMock(return_value="mock_refresh_token"), + ) + monkeypatch.setattr( + "app.auth.service.get_password_hash", lambda pwd: f"hashed_{pwd}" + ) + + result = await service.create_user_with_email( + "new@example.com", "securepass", "Test User" + ) assert result["user"]["email"] == "new@example.com" assert result["user"]["hashed_password"] == "hashed_securepass" assert result["refresh_token"] == "mock_refresh_token" + @pytest.mark.asyncio async def test_create_user_with_email_already_exists(monkeypatch): service = AuthService() @@ -55,6 +66,7 @@ async def test_create_user_with_email_already_exists(monkeypatch): assert exc.value.status_code == 400 assert exc.value.detail == "User with this email already exists" + @pytest.mark.asyncio async def test_create_user_with_email_refresh_token_error(monkeypatch): service = AuthService() @@ -65,12 +77,15 @@ async def test_create_user_with_email_refresh_token_error(monkeypatch): mock_db.users.insert_one.return_value = mock_insert_result monkeypatch.setattr(service, "get_db", lambda: mock_db) - monkeypatch.setattr("app.auth.service.get_password_hash", lambda pwd: f"hashed_{pwd}") + monkeypatch.setattr( + "app.auth.service.get_password_hash", lambda pwd: f"hashed_{pwd}" + ) async def fail_refresh_token(*args, **kwargs): raise Exception("Token generation failed") - monkeypatch.setattr(service, "_create_refresh_token_record", fail_refresh_token) + monkeypatch.setattr( + service, "_create_refresh_token_record", fail_refresh_token) with pytest.raises(HTTPException) as exc: await service.create_user_with_email("fail@example.com", "pass", "User") @@ -78,7 +93,6 @@ async def fail_refresh_token(*args, **kwargs): assert exc.value.status_code == 500 assert exc.value.detail == "Internal server error" -from pymongo.errors import DuplicateKeyError @pytest.mark.asyncio async def test_create_user_with_email_duplicate_key(monkeypatch): @@ -89,7 +103,9 @@ async def test_create_user_with_email_duplicate_key(monkeypatch): mock_db.users.insert_one.side_effect = DuplicateKeyError("dup key") monkeypatch.setattr(service, "get_db", lambda: mock_db) - monkeypatch.setattr("app.auth.service.get_password_hash", lambda pwd: f"hashed_{pwd}") + monkeypatch.setattr( + "app.auth.service.get_password_hash", lambda pwd: f"hashed_{pwd}" + ) monkeypatch.setattr(service, "_create_refresh_token_record", AsyncMock()) with pytest.raises(HTTPException) as exc: @@ -98,27 +114,36 @@ async def test_create_user_with_email_duplicate_key(monkeypatch): assert exc.value.status_code == 400 assert exc.value.detail == "User with this email already exists" + @pytest.mark.asyncio async def test_authenticate_user_success(monkeypatch): service = AuthService() mock_user = { "_id": ObjectId(), "email": "test@example.com", - "hashed_password": "mocked_hash" + "hashed_password": "mocked_hash", } mock_db = AsyncMock() mock_db.users.find_one.return_value = mock_user monkeypatch.setattr(service, "get_db", lambda: mock_db) - monkeypatch.setattr("app.auth.service.verify_password", lambda pwd, hash: pwd == "correct-password") - monkeypatch.setattr(service, "_create_refresh_token_record", AsyncMock(return_value="refresh-token")) - - result = await service.authenticate_user_with_email("test@example.com", "correct-password") + monkeypatch.setattr( + "app.auth.service.verify_password", lambda pwd, hash: pwd == "correct-password" + ) + monkeypatch.setattr( + service, "_create_refresh_token_record", AsyncMock( + return_value="refresh-token") + ) + + result = await service.authenticate_user_with_email( + "test@example.com", "correct-password" + ) assert result.get("user") == mock_user assert result.get("refresh_token") == "refresh-token" + @pytest.mark.asyncio async def test_authenticate_user_db_error(monkeypatch): service = AuthService() @@ -129,7 +154,7 @@ async def test_authenticate_user_db_error(monkeypatch): with pytest.raises(HTTPException) as e: await service.authenticate_user_with_email("email", "pass") - + assert e.value.status_code == 500 assert "Internal server error" in e.value.detail @@ -152,13 +177,18 @@ async def test_authenticate_user_not_found(monkeypatch): @pytest.mark.asyncio async def test_authenticate_user_password_incorrect(monkeypatch): service = AuthService() - mock_user = {"_id": ObjectId(), "email": "test@example.com", "hashed_password": "hashed"} + mock_user = { + "_id": ObjectId(), + "email": "test@example.com", + "hashed_password": "hashed", + } mock_db = AsyncMock() mock_db.users.find_one.return_value = mock_user monkeypatch.setattr(service, "get_db", lambda: mock_db) - monkeypatch.setattr("app.auth.service.verify_password", lambda pwd, hash: False) + monkeypatch.setattr("app.auth.service.verify_password", + lambda pwd, hash: False) with pytest.raises(HTTPException) as e: await service.authenticate_user_with_email("email", "wrongpass") @@ -170,34 +200,42 @@ async def test_authenticate_user_password_incorrect(monkeypatch): @pytest.mark.asyncio async def test_authenticate_user_missing_hashed_password(monkeypatch): service = AuthService() - mock_user = {"_id": ObjectId(), "email": "test@example.com"} # no hashed_password + # no hashed_password + mock_user = {"_id": ObjectId(), "email": "test@example.com"} mock_db = AsyncMock() mock_db.users.find_one.return_value = mock_user monkeypatch.setattr(service, "get_db", lambda: mock_db) - monkeypatch.setattr("app.auth.service.verify_password", lambda pwd, hash: False) + monkeypatch.setattr("app.auth.service.verify_password", + lambda pwd, hash: False) with pytest.raises(HTTPException) as e: await service.authenticate_user_with_email("email", "pass") assert e.value.status_code == 401 + @pytest.mark.asyncio async def test_authenticate_user_refresh_token_error(monkeypatch): service = AuthService() mock_user = { "_id": ObjectId(), "email": "test@example.com", - "hashed_password": "mocked_hash" + "hashed_password": "mocked_hash", } mock_db = AsyncMock() mock_db.users.find_one.return_value = mock_user monkeypatch.setattr(service, "get_db", lambda: mock_db) - monkeypatch.setattr("app.auth.service.verify_password", lambda pwd, hash: True) - monkeypatch.setattr(service, "_create_refresh_token_record", AsyncMock(side_effect=Exception("fail"))) + monkeypatch.setattr("app.auth.service.verify_password", + lambda pwd, hash: True) + monkeypatch.setattr( + service, + "_create_refresh_token_record", + AsyncMock(side_effect=Exception("fail")), + ) with pytest.raises(HTTPException) as e: await service.authenticate_user_with_email("email", "pass") @@ -205,6 +243,7 @@ async def test_authenticate_user_refresh_token_error(monkeypatch): assert e.value.status_code == 500 assert "Failed to generate refresh token" in e.value.detail + @pytest.mark.asyncio async def test_authenticate_with_google_success(mocker): mock_token = "valid-id-token" @@ -213,11 +252,13 @@ async def test_authenticate_with_google_success(mocker): "uid": "firebase-uid-123", "email": "test@example.com", "name": "Test User", - "picture": "http://example.com/avatar.jpg" + "picture": "http://example.com/avatar.jpg", } # Mock firebase_auth.verify_id_token - mocker.patch("app.auth.service.firebase_auth.verify_id_token", return_value=decoded_token) + mocker.patch( + "app.auth.service.firebase_auth.verify_id_token", return_value=decoded_token + ) # Mock db mock_db = AsyncMock() @@ -225,7 +266,9 @@ async def test_authenticate_with_google_success(mocker): mock_db.users.insert_one.return_value.inserted_id = mock_user_id mocker.patch.object(AuthService, "get_db", return_value=mock_db) - mocker.patch.object(AuthService, "_create_refresh_token_record", return_value="new_refresh_token") + mocker.patch.object( + AuthService, "_create_refresh_token_record", return_value="new_refresh_token" + ) service = AuthService() result = await service.authenticate_with_google(mock_token) @@ -233,9 +276,13 @@ async def test_authenticate_with_google_success(mocker): assert result["user"]["email"] == "test@example.com" assert result["refresh_token"] == "new_refresh_token" + @pytest.mark.asyncio async def test_authenticate_with_google_invalid_token(mocker): - mocker.patch("app.auth.service.firebase_auth.verify_id_token", side_effect=firebase_auth.InvalidIdTokenError("bad token")) + mocker.patch( + "app.auth.service.firebase_auth.verify_id_token", + side_effect=firebase_auth.InvalidIdTokenError("bad token"), + ) service = AuthService() with pytest.raises(HTTPException) as exc_info: @@ -244,12 +291,15 @@ async def test_authenticate_with_google_invalid_token(mocker): assert exc_info.value.status_code == 401 assert "Invalid Google ID token" in str(exc_info.value.detail) + @pytest.mark.asyncio async def test_authenticate_with_google_missing_email(mocker): decoded_token = {"uid": "uid123"} # no email - mocker.patch("app.auth.service.firebase_auth.verify_id_token", return_value=decoded_token) + mocker.patch( + "app.auth.service.firebase_auth.verify_id_token", return_value=decoded_token + ) service = AuthService() with pytest.raises(HTTPException) as exc_info: @@ -258,14 +308,14 @@ async def test_authenticate_with_google_missing_email(mocker): assert exc_info.value.status_code == 400 assert "Email not provided" in str(exc_info.value.detail) + @pytest.mark.asyncio async def test_authenticate_with_google_db_find_error(mocker): - decoded_token = { - "uid": "uid123", - "email": "test@example.com" - } + decoded_token = {"uid": "uid123", "email": "test@example.com"} - mocker.patch("app.auth.service.firebase_auth.verify_id_token", return_value=decoded_token) + mocker.patch( + "app.auth.service.firebase_auth.verify_id_token", return_value=decoded_token + ) mock_db = AsyncMock() mock_db.users.find_one.side_effect = PyMongoError("db error") @@ -277,6 +327,7 @@ async def test_authenticate_with_google_db_find_error(mocker): assert exc_info.value.status_code == 500 + @pytest.mark.asyncio async def test_authenticate_with_google_insert_error(mocker): decoded_token = { @@ -284,7 +335,9 @@ async def test_authenticate_with_google_insert_error(mocker): "email": "test@example.com", } - mocker.patch("app.auth.service.firebase_auth.verify_id_token", return_value=decoded_token) + mocker.patch( + "app.auth.service.firebase_auth.verify_id_token", return_value=decoded_token + ) mock_db = AsyncMock() mock_db.users.find_one.return_value = None @@ -298,6 +351,7 @@ async def test_authenticate_with_google_insert_error(mocker): assert exc_info.value.status_code == 500 assert "Failed to create user" in str(exc_info.value.detail) + @pytest.mark.asyncio async def test_refresh_access_token_success(): service = AuthService() @@ -310,7 +364,7 @@ async def test_refresh_access_token_success(): "revoked": False, "expires_at": now + timedelta(hours=1), "user_id": "user123", - "_id": "token_id" + "_id": "token_id", } mock_user = {"_id": "user123", "email": "test@example.com"} @@ -318,12 +372,14 @@ async def test_refresh_access_token_success(): mock_db.users.find_one = AsyncMock(return_value=mock_user) mock_db.refresh_tokens.update_one = AsyncMock() - service._create_refresh_token_record = AsyncMock(return_value="new_refresh_token") + service._create_refresh_token_record = AsyncMock( + return_value="new_refresh_token") token = await service.refresh_access_token("valid_refresh_token") assert token == "new_refresh_token" mock_db.refresh_tokens.update_one.assert_called_once() + @pytest.mark.asyncio async def test_refresh_access_token_invalid_or_expired(): service = AuthService() @@ -336,6 +392,7 @@ async def test_refresh_access_token_invalid_or_expired(): assert e.value.status_code == status.HTTP_401_UNAUTHORIZED assert "Invalid or expired" in e.value.detail + @pytest.mark.asyncio async def test_refresh_access_token_user_not_found(): service = AuthService() @@ -346,7 +403,7 @@ async def test_refresh_access_token_user_not_found(): "token": "valid_token", "revoked": False, "expires_at": datetime.now(timezone.utc) + timedelta(hours=1), - "user_id": "user123" + "user_id": "user123", } mock_db.refresh_tokens.find_one = AsyncMock(return_value=mock_token_record) @@ -358,18 +415,21 @@ async def test_refresh_access_token_user_not_found(): assert e.value.status_code == status.HTTP_401_UNAUTHORIZED assert "User not found" in e.value.detail + @pytest.mark.asyncio async def test_refresh_access_token_db_failure_on_token(): service = AuthService() mock_db = MagicMock() service.get_db = MagicMock(return_value=mock_db) - mock_db.refresh_tokens.find_one = AsyncMock(side_effect=PyMongoError("DB error")) + mock_db.refresh_tokens.find_one = AsyncMock( + side_effect=PyMongoError("DB error")) with pytest.raises(HTTPException) as e: await service.refresh_access_token("any_token") assert e.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + @pytest.mark.asyncio async def test_refresh_access_token_db_failure_on_user_fetch(): service = AuthService() @@ -380,7 +440,7 @@ async def test_refresh_access_token_db_failure_on_user_fetch(): "token": "token", "revoked": False, "expires_at": datetime.now(timezone.utc) + timedelta(hours=1), - "user_id": "user123" + "user_id": "user123", } mock_db.refresh_tokens.find_one = AsyncMock(return_value=mock_token_record) @@ -391,6 +451,7 @@ async def test_refresh_access_token_db_failure_on_user_fetch(): assert e.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + @pytest.mark.asyncio async def test_refresh_access_token_generation_failure(): service = AuthService() @@ -402,14 +463,16 @@ async def test_refresh_access_token_generation_failure(): "revoked": False, "expires_at": datetime.now(timezone.utc) + timedelta(hours=1), "user_id": "user123", - "_id": "token_id" + "_id": "token_id", } mock_user = {"_id": "user123"} mock_db.refresh_tokens.find_one = AsyncMock(return_value=mock_token_record) mock_db.users.find_one = AsyncMock(return_value=mock_user) - service._create_refresh_token_record = AsyncMock(side_effect=Exception("Token gen fail")) + service._create_refresh_token_record = AsyncMock( + side_effect=Exception("Token gen fail") + ) with pytest.raises(HTTPException) as e: await service.refresh_access_token("token") @@ -417,12 +480,15 @@ async def test_refresh_access_token_generation_failure(): assert e.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR assert "Failed to create refresh token" in e.value.detail + @pytest.mark.asyncio async def test_verify_access_token_valid(monkeypatch): service = AuthService() # Mock verify_token to return a payload - monkeypatch.setattr("app.auth.security.verify_token", lambda token: {"sub": "user123"}) + monkeypatch.setattr( + "app.auth.security.verify_token", lambda token: {"sub": "user123"} + ) # Mock DB response mock_user = {"_id": "user123", "email": "test@example.com"} @@ -433,6 +499,7 @@ async def test_verify_access_token_valid(monkeypatch): user = await service.verify_access_token("validtoken") assert user == mock_user + @pytest.mark.asyncio async def test_verify_access_token_invalid_token(monkeypatch): service = AuthService() @@ -444,10 +511,11 @@ def raise_jwt_error(token): with pytest.raises(HTTPException) as exc_info: await service.verify_access_token("badtoken") - + assert exc_info.value.status_code == 401 assert exc_info.value.detail == "Invalid token" + @pytest.mark.asyncio async def test_verify_access_token_missing_sub(monkeypatch): service = AuthService() @@ -456,15 +524,18 @@ async def test_verify_access_token_missing_sub(monkeypatch): with pytest.raises(HTTPException) as exc_info: await service.verify_access_token("token") - + assert exc_info.value.status_code == 401 assert exc_info.value.detail == "Invalid token" + @pytest.mark.asyncio async def test_verify_access_token_db_error(monkeypatch): service = AuthService() - monkeypatch.setattr("app.auth.security.verify_token", lambda token: {"sub": "user123"}) + monkeypatch.setattr( + "app.auth.security.verify_token", lambda token: {"sub": "user123"} + ) mock_db = AsyncMock() mock_db.users.find_one.side_effect = Exception("DB failure") @@ -472,15 +543,18 @@ async def test_verify_access_token_db_error(monkeypatch): with pytest.raises(HTTPException) as exc_info: await service.verify_access_token("token") - + assert exc_info.value.status_code == 500 assert exc_info.value.detail == "Internal server error" + @pytest.mark.asyncio async def test_verify_access_token_user_not_found(monkeypatch): service = AuthService() - monkeypatch.setattr("app.auth.security.verify_token", lambda token: {"sub": "user123"}) + monkeypatch.setattr( + "app.auth.security.verify_token", lambda token: {"sub": "user123"} + ) mock_db = AsyncMock() mock_db.users.find_one.return_value = None @@ -488,31 +562,37 @@ async def test_verify_access_token_user_not_found(monkeypatch): with pytest.raises(HTTPException) as exc_info: await service.verify_access_token("token") - + assert exc_info.value.status_code == 401 assert exc_info.value.detail == "User not found" + @pytest.mark.asyncio async def test_request_password_reset_user_exists(monkeypatch, caplog): service = AuthService() mock_db = AsyncMock() mock_user = {"_id": "mock_user_id", "email": "test@example.com"} - + mock_db.users.find_one.return_value = mock_user mock_db.password_resets.insert_one.return_value = None - monkeypatch.setattr(service, "get_db", lambda: mock_db) #temporarily override get_db in function scope - + monkeypatch.setattr( + service, "get_db", lambda: mock_db + ) # temporarily override get_db in function scope + # Mock the token generator with patch("app.auth.service.generate_reset_token", return_value="mocktoken"): - with caplog.at_level(logging.INFO): #caplog captures the log messages (previously print statements) + with caplog.at_level( + logging.INFO + ): # caplog captures the log messages (previously print statements) result = await service.request_password_reset("test@example.com") # Assert assert result is True assert "mocktoken" in caplog.text assert "Reset link" in caplog.text - mock_db.users.find_one.assert_awaited_once_with({"email": "test@example.com"}) + mock_db.users.find_one.assert_awaited_once_with( + {"email": "test@example.com"}) mock_db.password_resets.insert_one.assert_awaited_once() @@ -551,7 +631,8 @@ async def test_request_password_reset_db_error_on_insert(monkeypatch): mock_db = AsyncMock() mock_user = {"_id": "mock_user_id", "email": "test@example.com"} mock_db.users.find_one.return_value = mock_user - mock_db.password_resets.insert_one.side_effect = PyMongoError("Insert failure") + mock_db.password_resets.insert_one.side_effect = PyMongoError( + "Insert failure") monkeypatch.setattr(service, "get_db", lambda: mock_db) @@ -562,6 +643,7 @@ async def test_request_password_reset_db_error_on_insert(monkeypatch): assert exc_info.value.status_code == 500 assert "token storage" in exc_info.value.detail.lower() + @pytest.mark.asyncio async def test_confirm_password_reset_success(): service = AuthService() @@ -570,18 +652,21 @@ async def test_confirm_password_reset_success(): future_time = datetime.now(timezone.utc) + timedelta(hours=1) - with patch.object(service, 'get_db', return_value=mock_db): + with patch.object(service, "get_db", return_value=mock_db): # Mock reset token lookup - mock_db.password_resets.find_one = AsyncMock(return_value={ - "token": "validtoken", - "used": False, - "expires_at": future_time, - "_id": ObjectId(), - "user_id": mock_user_id - }) + mock_db.password_resets.find_one = AsyncMock( + return_value={ + "token": "validtoken", + "used": False, + "expires_at": future_time, + "_id": ObjectId(), + "user_id": mock_user_id, + } + ) # Mock user update - mock_db.users.update_one = AsyncMock(return_value=MagicMock(modified_count=1)) + mock_db.users.update_one = AsyncMock( + return_value=MagicMock(modified_count=1)) mock_db.password_resets.update_one = AsyncMock() mock_db.refresh_tokens.update_many = AsyncMock() @@ -591,12 +676,13 @@ async def test_confirm_password_reset_success(): mock_db.users.update_one.assert_awaited_once() mock_db.refresh_tokens.update_many.assert_awaited_once() + @pytest.mark.asyncio async def test_confirm_password_reset_invalid_or_expired_token(): service = AuthService() mock_db = MagicMock() - with patch.object(service, 'get_db', return_value=mock_db): + with patch.object(service, "get_db", return_value=mock_db): # Simulate token not found (invalid, used, or expired) mock_db.password_resets.find_one = AsyncMock(return_value=None) @@ -611,6 +697,7 @@ async def test_confirm_password_reset_invalid_or_expired_token(): mock_db.password_resets.update_one.assert_not_called() mock_db.refresh_tokens.update_many.assert_not_called() + @pytest.mark.asyncio async def test_create_refresh_token_record_success(): service = AuthService() @@ -619,8 +706,9 @@ async def test_create_refresh_token_record_success(): mock_user_id = str(ObjectId()) # Patch get_db and create_refresh_token - with patch.object(service, 'get_db', return_value=mock_db), \ - patch('app.auth.service.create_refresh_token', return_value=mock_token): + with patch.object(service, "get_db", return_value=mock_db), patch( + "app.auth.service.create_refresh_token", return_value=mock_token + ): # Mock insert mock_db.refresh_tokens.insert_one = AsyncMock() @@ -630,17 +718,21 @@ async def test_create_refresh_token_record_success(): assert result == mock_token mock_db.refresh_tokens.insert_one.assert_awaited_once() + @pytest.mark.asyncio async def test_create_refresh_token_record_db_failure(): service = AuthService() mock_db = MagicMock() mock_user_id = str(ObjectId()) - with patch.object(service, 'get_db', return_value=mock_db), \ - patch('app.auth.service.create_refresh_token', return_value="badtoken"): + with patch.object(service, "get_db", return_value=mock_db), patch( + "app.auth.service.create_refresh_token", return_value="badtoken" + ): # DB failure - mock_db.refresh_tokens.insert_one = AsyncMock(side_effect=Exception("DB write error")) + mock_db.refresh_tokens.insert_one = AsyncMock( + side_effect=Exception("DB write error") + ) with pytest.raises(HTTPException) as exc_info: await service._create_refresh_token_record(mock_user_id) @@ -648,6 +740,7 @@ async def test_create_refresh_token_record_db_failure(): assert exc_info.value.status_code == 500 assert "Failed to create refresh token" in exc_info.value.detail + @pytest.mark.asyncio async def test_create_refresh_token_record_invalid_user_id(): service = AuthService() diff --git a/backend/tests/expenses/test_expense_service.py b/backend/tests/expenses/test_expense_service.py index 8c1fce67..ebcdf964 100644 --- a/backend/tests/expenses/test_expense_service.py +++ b/backend/tests/expenses/test_expense_service.py @@ -1,11 +1,10 @@ -import pytest -from fastapi import HTTPException from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.expenses.schemas import ExpenseCreateRequest, ExpenseSplit, SplitType from app.expenses.service import ExpenseService from bson import ObjectId +from fastapi import HTTPException @pytest.fixture @@ -119,7 +118,7 @@ async def test_create_expense_invalid_group(expense_service): mock_db = MagicMock() mock_mongodb.database = mock_db mock_db.groups.find_one = AsyncMock(return_value=None) - + '''# Test with invalid ObjectId format with pytest.raises(ValueError, match="Group not found or user not a member"): await expense_service.create_expense( @@ -142,6 +141,7 @@ async def test_create_expense_invalid_group(expense_service): assert exc_info_2.value.status_code == 403 assert "not a member of this group" in str(exc_info_2.value.detail) + @pytest.mark.asyncio async def test_calculate_optimized_settlements_advanced(expense_service): """Test advanced settlement algorithm with real optimization logic""" @@ -352,12 +352,12 @@ async def test_update_expense_unauthorized(expense_service): # Mock finding no expense (user not creator) mock_db.expenses.find_one = AsyncMock(return_value=None) - + '''with pytest.raises(ValueError, match="Expense not found or not authorized to edit"): await expense_service.update_expense( - "group_id", + "group_id", "65f1a2b3c4d5e6f7a8b9c0d1", - update_request, + update_request, "unauthorized_user" ) assert exc_info.value.status_code == 403 @@ -484,7 +484,7 @@ async def test_get_expense_by_id_not_found(expense_service): # Mock expense not found mock_db.expenses.find_one = AsyncMock(return_value=None) - + ''' with pytest.raises(ValueError, match="Expense not found"): await expense_service.get_expense_by_id("65f1a2b3c4d5e6f7a8b9c0d0", "65f1a2b3c4d5e6f7a8b9c0d1", "user_a")''' # Updated after stricter exception handling (July 2025) @@ -787,7 +787,7 @@ async def test_delete_expense_not_found(expense_service): # Updated after stricter exception handling (July 2025) with pytest.raises(HTTPException) as exc_info: await expense_service.delete_expense(group_id, expense_id, user_id) - + assert exc_info.value.status_code == 403 assert exc_info.value.detail == "Not authorized to delete this expense or it does not exist" @@ -1285,7 +1285,8 @@ async def test_update_settlement_status_not_found(expense_service): assert exc_info.value.status_code == 404 assert exc_info.value.detail == "Settlement not found" - mock_db.settlements.find_one.assert_not_called() # Should not be called if update fails + # Should not be called if update fails + mock_db.settlements.find_one.assert_not_called() @pytest.mark.asyncio async def test_delete_settlement_success(expense_service, mock_group_data): diff --git a/backend/tests/groups/test_groups_service.py b/backend/tests/groups/test_groups_service.py index abfd6a1c..ca95b64c 100644 --- a/backend/tests/groups/test_groups_service.py +++ b/backend/tests/groups/test_groups_service.py @@ -447,21 +447,23 @@ async def test_leave_group_allow_member_to_leave(self): @pytest.mark.asyncio async def test_get_group_by_id_invalid_objectid(self): """Test get_group_by_id with invalid ObjectId format""" - with patch.object(self.service, 'get_db'): + with patch.object(self.service, "get_db"): result = await self.service.get_group_by_id("invalid-id", "user123") assert result is None @pytest.mark.asyncio async def test_update_group_invalid_objectid(self): """Test update_group with invalid ObjectId""" - with patch.object(self.service, 'get_db'): - result = await self.service.update_group("invalid-id", {"name": "test"}, "user123") + with patch.object(self.service, "get_db"): + result = await self.service.update_group( + "invalid-id", {"name": "test"}, "user123" + ) assert result is None @pytest.mark.asyncio async def test_delete_group_invalid_objectid(self): """Test delete_group with invalid ObjectId""" - with patch.object(self.service, 'get_db'): + with patch.object(self.service, "get_db"): result = await self.service.delete_group("invalid-id", "user123") assert result is False @@ -469,7 +471,7 @@ def test_transform_group_document_partial_input(self): """Test transform with partial group fields""" group_doc = { "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"), - "name": "Partial Group" + "name": "Partial Group", } result = self.service.transform_group_document(group_doc) diff --git a/backend/tests/user/test_user_service.py b/backend/tests/user/test_user_service.py index aa8e86a3..e4d8bb9a 100644 --- a/backend/tests/user/test_user_service.py +++ b/backend/tests/user/test_user_service.py @@ -179,6 +179,7 @@ async def test_get_user_by_id_not_found(mock_db_client, mock_get_database): {"_id": TEST_OBJECT_ID}) assert user is None + # Added Test for invalid ObjectId format @pytest.mark.asyncio async def test_get_user_by_id_invalid_objectid(mock_db_client, mock_get_database): @@ -190,6 +191,7 @@ async def test_get_user_by_id_invalid_objectid(mock_db_client, mock_get_database mock_db_client.users.find_one.assert_not_called() assert user is None + # --- Tests for update_user_profile --- @@ -249,6 +251,7 @@ async def test_update_user_profile_user_not_found(mock_db_client, mock_get_datab assert kwargs["return_document"] is True assert updated_user is None + # Added Test for invalid ObjectId format for user update @pytest.mark.asyncio async def test_update_user_profile_invalid_object_id(mock_db_client, mock_get_database): @@ -261,6 +264,7 @@ async def test_update_user_profile_invalid_object_id(mock_db_client, mock_get_da mock_db_client.users.find_one_and_update.assert_not_called() assert updated_user is None + # --- Tests for delete_user --- @@ -289,6 +293,7 @@ async def test_delete_user_not_found(mock_db_client, mock_get_database): {"_id": TEST_OBJECT_ID}) assert result is False + # Added Test for invalid ObjectId format for user deletion @pytest.mark.asyncio async def test_delete_user_invalid_object_id(mock_db_client, mock_get_database): @@ -300,6 +305,7 @@ async def test_delete_user_invalid_object_id(mock_db_client, mock_get_database): mock_db_client.users.delete_one.assert_not_called() assert result is False + # Added Test for invalid ObjectId format for user deletion @pytest.mark.asyncio async def test_delete_user_invalid_object_id(mock_db_client, mock_get_database): From fea77e8866e8386a92aab9d3180e88e8be921d72 Mon Sep 17 00:00:00 2001 From: Samudra Date: Tue, 29 Jul 2025 00:22:08 +0530 Subject: [PATCH 4/5] fixed errors, missing imports, and test coverage up to 74% for app --- backend/.coverage | Bin 159744 -> 0 bytes backend/app/auth/service.py | 10 ++++ backend/app/expenses/service.py | 5 +- backend/app/groups/service.py | 19 +----- backend/tests/auth/test_auth_service.py | 56 +++++++++++++++++- backend/tests/expenses/test_expense_routes.py | 1 + .../tests/expenses/test_expense_service.py | 16 +++-- backend/tests/groups/test_groups_service.py | 1 - backend/tests/user/test_user_service.py | 10 ---- 9 files changed, 82 insertions(+), 36 deletions(-) delete mode 100644 backend/.coverage diff --git a/backend/.coverage b/backend/.coverage deleted file mode 100644 index 2f88927bdabb2dc69b24bdfbdd9f644d2219e4ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 159744 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 ZG9L)uc#?Tv@P>_W&)s*6YFzW~{{RZeYC8Y` diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 5d78ebdf..eb46c16a 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -1,8 +1,18 @@ import json import os +import firebase_admin from datetime import datetime, timedelta, timezone from typing import Optional, Dict, Any from pymongo.errors import DuplicateKeyError, PyMongoError +from app.config import settings, logger +from app.auth.security import ( + get_password_hash, + verify_password, + create_access_token, + create_refresh_token, + generate_reset_token +) +from app.database import get_database from bson import ObjectId from jose import JWTError from fastapi import HTTPException, status diff --git a/backend/app/expenses/service.py b/backend/app/expenses/service.py index 71ecd9fe..0b49515b 100644 --- a/backend/app/expenses/service.py +++ b/backend/app/expenses/service.py @@ -1,7 +1,7 @@ from fastapi import HTTPException, status from typing import List, Dict, Any, Optional, Tuple from datetime import datetime, timedelta -from bson import ObjectId +from bson import ObjectId, errors from app.database import mongodb from app.config import logger from app.database import mongodb @@ -15,8 +15,7 @@ SettlementStatus, SplitType, ) -from bson import ObjectId - +from collections import defaultdict class ExpenseService: def __init__(self): diff --git a/backend/app/groups/service.py b/backend/app/groups/service.py index a6c437fd..04476540 100644 --- a/backend/app/groups/service.py +++ b/backend/app/groups/service.py @@ -2,12 +2,11 @@ import string from datetime import datetime, timezone from typing import Any, Dict, List, Optional - +from fastapi import HTTPException from app.database import get_database from app.config import logger from bson import ObjectId, errors from datetime import datetime, timezone -from typing import Optional, Dict, Any, List import secrets import string @@ -98,21 +97,7 @@ async def _enrich_members_with_user_details( "avatar": None } }) - except Exception as e: - logger.error(f"Error enriching userId {member_user_id}: {e}") - # If user lookup fails, add member with basic info - enriched_members.append( - { - "userId": member_user_id, - "role": member.get("role", "member"), - "joinedAt": member.get("joinedAt"), - "user": { - "name": f"User {member_user_id[-4:]}", - "email": f"{member_user_id}@example.com", - "avatar": None, - }, - } - ) + else: # Add member without user details if userId is missing enriched_members.append(member) diff --git a/backend/tests/auth/test_auth_service.py b/backend/tests/auth/test_auth_service.py index 6d4d886d..5a562d43 100644 --- a/backend/tests/auth/test_auth_service.py +++ b/backend/tests/auth/test_auth_service.py @@ -1,9 +1,62 @@ +""" +Test suite for the `AuthService` class in the `app.auth.service` module. + +This file contains extensive async tests using `pytest` and `unittest.mock` to validate +the behavior of authentication-related features in a FastAPI-based application, including: + +1. **User Registration** + - Successful creation of a new user with email and password. + - Handling of duplicate email entries (via `find_one` and `DuplicateKeyError`). + - Failure to create refresh token during user registration. + +2. **Email/Password Login** + - Successful authentication with correct credentials. + - Handling incorrect password or nonexistent user. + - Missing hashed password in the database. + - DB errors during user retrieval. + - Failure in refresh token generation after successful login. + +3. **Google Sign-In (OAuth)** + - Successful authentication using a valid Google ID token. + - Handling invalid or missing tokens. + - Missing email in decoded token. + - MongoDB-related errors during find/insert operations. + +4. **Refresh Token Workflow** + - Successful issuance of new access token using a valid refresh token. + - Handling of invalid, expired, or revoked tokens. + - Missing user associated with the refresh token. + - DB errors during token or user fetch. + - Failure during new token generation. + +5. **Access Token Verification** + - Verifying access tokens for authenticated requests. + - Handling invalid tokens (JWT errors or missing `sub` claim). + - DB errors or missing user during verification. + +6. **Password Reset Flow** + - Requesting a password reset for an existing user and generating reset token. + - Ignoring requests for nonexistent users. + - Handling DB errors during lookup or insertion of reset tokens. + - Confirming password reset with valid token and updating user password. + - Rejecting invalid, expired, or already-used reset tokens. + +7. **Refresh Token Record Creation** + - Inserting a new refresh token record for a user. + - Handling DB insertion errors and invalid user IDs. + +All tests simulate various edge cases and exceptions to ensure robust handling of errors +with appropriate HTTP response codes and messages. +""" + import pytest from unittest.mock import AsyncMock, patch, MagicMock from fastapi import HTTPException, status from bson import ObjectId from jose import JWTError from firebase_admin import auth as firebase_auth +from firebase_admin import credentials +import firebase_admin from datetime import datetime, timedelta, timezone from app.auth.service import AuthService from app.auth.security import get_password_hash, create_refresh_token, verify_password @@ -20,7 +73,8 @@ def validate_object_id(id_str: str, field_name: str = "ID") -> ObjectId: status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid {field_name}" ) - + +@pytest.mark.asyncio async def test_create_user_with_email_success(monkeypatch): service = AuthService() diff --git a/backend/tests/expenses/test_expense_routes.py b/backend/tests/expenses/test_expense_routes.py index f55ee2fd..fbf3cfb7 100644 --- a/backend/tests/expenses/test_expense_routes.py +++ b/backend/tests/expenses/test_expense_routes.py @@ -4,6 +4,7 @@ from app.expenses.schemas import ExpenseCreateRequest, ExpenseSplit from fastapi import status from httpx import ASGITransport, AsyncClient +from firebase_admin import auth as firebase_auth from main import app # Adjusted import diff --git a/backend/tests/expenses/test_expense_service.py b/backend/tests/expenses/test_expense_service.py index 8c1fce67..7ea3138d 100644 --- a/backend/tests/expenses/test_expense_service.py +++ b/backend/tests/expenses/test_expense_service.py @@ -1,12 +1,12 @@ import pytest from fastapi import HTTPException from unittest.mock import AsyncMock, MagicMock, patch - +from datetime import datetime, timedelta, timezone import pytest from app.expenses.schemas import ExpenseCreateRequest, ExpenseSplit, SplitType from app.expenses.service import ExpenseService -from bson import ObjectId - +from bson import ObjectId, errors +import asyncio @pytest.fixture def expense_service(): @@ -344,7 +344,7 @@ async def test_update_expense_unauthorized(expense_service): """Test expense update by non-creator""" from app.expenses.schemas import ExpenseUpdateRequest - update_request = ExpenseUpdateRequest(description="Unauthorized Update") + update_request = ExpenseUpdateRequest(description="Unauthorized Update", amount=150.0) with patch("app.expenses.service.mongodb") as mock_mongodb: mock_db = MagicMock() @@ -354,6 +354,14 @@ async def test_update_expense_unauthorized(expense_service): mock_db.expenses.find_one = AsyncMock(return_value=None) '''with pytest.raises(ValueError, match="Expense not found or not authorized to edit"): + await expense_service.update_expense( + "group_id", + "65f1a2b3c4d5e6f7a8b9c0d1", + update_request, + "unauthorized_user" + )''' + #Updated test + with pytest.raises(HTTPException) as exc_info: await expense_service.update_expense( "group_id", "65f1a2b3c4d5e6f7a8b9c0d1", diff --git a/backend/tests/groups/test_groups_service.py b/backend/tests/groups/test_groups_service.py index abfd6a1c..03e35fa8 100644 --- a/backend/tests/groups/test_groups_service.py +++ b/backend/tests/groups/test_groups_service.py @@ -1,5 +1,4 @@ from unittest.mock import AsyncMock, MagicMock, patch - import pytest from app.groups.service import GroupService from bson import ObjectId diff --git a/backend/tests/user/test_user_service.py b/backend/tests/user/test_user_service.py index aa8e86a3..92c486ae 100644 --- a/backend/tests/user/test_user_service.py +++ b/backend/tests/user/test_user_service.py @@ -289,16 +289,6 @@ async def test_delete_user_not_found(mock_db_client, mock_get_database): {"_id": TEST_OBJECT_ID}) assert result is False -# Added Test for invalid ObjectId format for user deletion -@pytest.mark.asyncio -async def test_delete_user_invalid_object_id(mock_db_client, mock_get_database): - invalid_user_id = "invalid_object_id" # Not a valid 24-char hex string - - result = await user_service.delete_user(invalid_user_id) - - # Expected result: False and never hit the DB - mock_db_client.users.delete_one.assert_not_called() - assert result is False # Added Test for invalid ObjectId format for user deletion @pytest.mark.asyncio From 355eae98a7bf434200af456652312fa8eb8e5b03 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 19:16:54 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- backend/app/auth/service.py | 226 ++++++++++-------- backend/app/expenses/service.py | 9 +- backend/app/groups/service.py | 10 +- backend/tests/auth/test_auth_service.py | 25 +- backend/tests/expenses/test_expense_routes.py | 2 +- .../tests/expenses/test_expense_service.py | 103 ++++---- backend/tests/groups/test_groups_service.py | 1 + backend/tests/user/test_user_service.py | 1 - 8 files changed, 209 insertions(+), 168 deletions(-) diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index eb46c16a..9afe1af9 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -1,24 +1,24 @@ import json import os -import firebase_admin from datetime import datetime, timedelta, timezone -from typing import Optional, Dict, Any -from pymongo.errors import DuplicateKeyError, PyMongoError -from app.config import settings, logger -from app.auth.security import ( - get_password_hash, - verify_password, +from typing import Any, Dict, Optional + +import firebase_admin +from app.auth.security import ( create_access_token, create_refresh_token, - generate_reset_token + generate_reset_token, + get_password_hash, + verify_password, ) +from app.config import logger, settings from app.database import get_database from bson import ObjectId -from jose import JWTError from fastapi import HTTPException, status from firebase_admin import auth as firebase_auth from firebase_admin import credentials -from pymongo.errors import DuplicateKeyError +from jose import JWTError +from pymongo.errors import DuplicateKeyError, PyMongoError # Initialize Firebase Admin SDK if not firebase_admin._apps: @@ -142,7 +142,7 @@ async def create_user_with_email( logger.exception("Unexpected error while creating user with email") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" + detail="Internal server error", ) async def authenticate_user_with_email( @@ -163,16 +163,16 @@ async def authenticate_user_with_email( logger.error(f"Database error during user lookup: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" + detail="Internal server error", ) - + if not user or not verify_password(password, user.get("hashed_password", "")): logger.info("Authentication failed due to invalid credentials.") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password", ) - + # Create new refresh token try: refresh_token = await self._create_refresh_token_record(str(user["_id"])) @@ -180,12 +180,9 @@ async def authenticate_user_with_email( logger.error(f"Failed to generate refresh token: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to generate refresh token" + detail="Failed to generate refresh token", ) - return { - "user": user, - "refresh_token": refresh_token - } + return {"user": user, "refresh_token": refresh_token} async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: """ @@ -203,17 +200,18 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: # Verify the Firebase ID token try: decoded_token = firebase_auth.verify_id_token(id_token) - except firebase_auth.InvalidIdTokenError: + except firebase_auth.InvalidIdTokenError: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid Google ID token" + detail="Invalid Google ID token", ) - firebase_uid = decoded_token['uid'] - email = decoded_token.get('email') - name = decoded_token.get('name', email.split('@')[0] if email else 'User') - picture = decoded_token.get('picture') - + firebase_uid = decoded_token["uid"] + email = decoded_token.get("email") + name = decoded_token.get( + "name", email.split("@")[0] if email else "User") + picture = decoded_token.get("picture") + if not email: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -224,15 +222,14 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: # Check if user exists try: - user = await db.users.find_one({"$or": [ - {"email": email}, - {"firebase_uid": firebase_uid} - ]}) + user = await db.users.find_one( + {"$or": [{"email": email}, {"firebase_uid": firebase_uid}]} + ) except PyMongoError as e: logger.error("Database error while checking user: %s", str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" + detail="Internal server error", ) if user: # Update user info if needed @@ -245,12 +242,12 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: if update_data: try: await db.users.update_one( - {"_id": user["_id"]}, - {"$set": update_data} + {"_id": user["_id"]}, {"$set": update_data} ) user.update(update_data) except PyMongoError as e: - logger.warning("Failed to update user profile: %s", str(e)) + logger.warning( + "Failed to update user profile: %s", str(e)) else: # Create new user user_doc = { @@ -268,28 +265,31 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: user_doc["_id"] = result.inserted_id user = user_doc except PyMongoError as e: - logger.error("Failed to create new Google user: %s", str(e)) + logger.error( + "Failed to create new Google user: %s", str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create user" + detail="Failed to create user", ) - + # Create refresh token try: - refresh_token = await self._create_refresh_token_record(str(user["_id"])) + refresh_token = await self._create_refresh_token_record( + str(user["_id"]) + ) except Exception as e: - logger.error("Failed to issue refresh token for Google login: %s", str(e)) + logger.error( + "Failed to issue refresh token for Google login: %s", str( + e) + ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to generate refresh token" + detail="Failed to generate refresh token", ) - return { - "user": user, - "refresh_token": refresh_token - } + return {"user": user, "refresh_token": refresh_token} except HTTPException: - raise + raise except Exception as e: logger.exception("Unexpected error during Google authentication") raise HTTPException( @@ -313,18 +313,21 @@ async def refresh_access_token(self, refresh_token: str) -> str: # Find and validate refresh token try: - token_record = await db.refresh_tokens.find_one({ - "token": refresh_token, - "revoked": False, - "expires_at": {"$gt": datetime.now(timezone.utc)} - }) + token_record = await db.refresh_tokens.find_one( + { + "token": refresh_token, + "revoked": False, + "expires_at": {"$gt": datetime.now(timezone.utc)}, + } + ) except PyMongoError as e: - logger.error("Database error while validating refresh token: %s", str(e)) + logger.error( + "Database error while validating refresh token: %s", str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" + detail="Internal server error", ) - + if not token_record: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -338,7 +341,7 @@ async def refresh_access_token(self, refresh_token: str) -> str: logger.error("Error while fetching user: %s", str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" + detail="Internal server error", ) if not user: raise HTTPException( @@ -347,26 +350,27 @@ async def refresh_access_token(self, refresh_token: str) -> str: # Create new refresh token (token rotation) try: - new_refresh_token = await self._create_refresh_token_record(str(user["_id"])) + new_refresh_token = await self._create_refresh_token_record( + str(user["_id"]) + ) except Exception as e: logger.error("Failed to create new refresh token: %s", str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create refresh token" + detail="Failed to create refresh token", ) - + # Revoke old token try: await db.refresh_tokens.update_one( - {"_id": token_record["_id"]}, - {"$set": {"revoked": True}} + {"_id": token_record["_id"]}, {"$set": {"revoked": True}} ) except PyMongoError as e: logger.error("Failed to revoke old refresh token: %s", str(e)) # No raise here since new token is safely issued - - return new_refresh_token - + + return new_refresh_token + async def verify_access_token(self, token: str) -> Dict[str, Any]: """ Verifies an access token and retrieves the associated user. @@ -381,17 +385,16 @@ async def verify_access_token(self, token: str) -> Dict[str, Any]: HTTPException: If the token is invalid or the user does not exist. """ from app.auth.security import verify_token - + try: payload = verify_token(token) user_id = payload.get("sub") except JWTError as e: logger.warning("JWT verification failed: %s", str(e)) raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token" + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" ) - + if not user_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" @@ -405,9 +408,9 @@ async def verify_access_token(self, token: str) -> Dict[str, Any]: logger.error("Error while verifying token: %s", str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error" + detail="Internal server error", ) - + if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" @@ -422,13 +425,17 @@ async def request_password_reset(self, email: str) -> bool: If the user exists, generates a password reset token with a 1-hour expiration and stores it in the database. The reset token and link are logged for development purposes. Always returns True to avoid revealing whether the email is registered. """ db = self.get_db() - + try: user = await db.users.find_one({"email": email}) except PyMongoError as e: - logger.error(f"Database error while fetching user by email {email}: {str(e)}") - raise HTTPException(status_code=500, detail="Internal server error during user lookup.") - + logger.error( + f"Database error while fetching user by email {email}: {str(e)}" + ) + raise HTTPException( + status_code=500, detail="Internal server error during user lookup." + ) + if not user: # Don't reveal if email exists or not return True @@ -441,17 +448,21 @@ async def request_password_reset(self, email: str) -> bool: try: # Store reset token await db.password_resets.insert_one( - { + { "user_id": user["_id"], "token": reset_token, "expires_at": reset_expires, "used": False, "created_at": datetime.utcnow(), } - ) + ) except PyMongoError as e: - logger.error(f"Database error while storing reset token for user {email}: {str(e)}") - raise HTTPException(status_code=500, detail="Internal server error during token storage.") + logger.error( + f"Database error while storing reset token for user {email}: {str(e)}" + ) + raise HTTPException( + status_code=500, detail="Internal server error during token storage." + ) # For development/free tier: just log the reset token # In production, you would send this via email @@ -479,52 +490,56 @@ async def confirm_password_reset(self, reset_token: str, new_password: str) -> b HTTPException: If the reset token is invalid or expired. """ db = self.get_db() - + try: # Find and validate reset token - reset_record = await db.password_resets.find_one({ - "token": reset_token, - "used": False, - "expires_at": {"$gt": datetime.now(timezone.utc)} - }) - + reset_record = await db.password_resets.find_one( + { + "token": reset_token, + "used": False, + "expires_at": {"$gt": datetime.now(timezone.utc)}, + } + ) + if not reset_record: logger.warning("Invalid or expired reset token") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid or expired reset token" + detail="Invalid or expired reset token", ) - + # Update user password new_hash = get_password_hash(new_password) await db.users.update_one( {"_id": reset_record["user_id"]}, - {"$set": {"hashed_password": new_hash}} + {"$set": {"hashed_password": new_hash}}, ) - + # Mark token as used await db.password_resets.update_one( - {"_id": reset_record["_id"]}, - {"$set": {"used": True}} + {"_id": reset_record["_id"]}, {"$set": {"used": True}} ) - + # Revoke all refresh tokens for this user (force re-login) await db.refresh_tokens.update_many( - {"user_id": reset_record["user_id"]}, - {"$set": {"revoked": True}} + {"user_id": reset_record["user_id"]}, { + "$set": {"revoked": True}} + ) + logger.info( + f"Password reset successful for user_id: {reset_record['user_id']}" ) - logger.info(f"Password reset successful for user_id: {reset_record['user_id']}") - return True + return True except HTTPException: raise # Raising HTTPException to avoid logging again except Exception as e: - logger.exception(f"Unexpected error during password reset: {str(e)}") + logger.exception( + f"Unexpected error during password reset: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Internal server error during password reset" - ) - + detail="Internal server error during password reset", + ) + async def _create_refresh_token_record(self, user_id: str) -> str: """ Generates and stores a new refresh token for the specified user. @@ -546,19 +561,24 @@ async def _create_refresh_token_record(self, user_id: str) -> str: try: await db.refresh_tokens.insert_one( - { + { "token": refresh_token, - "user_id": ObjectId(user_id) if isinstance(user_id, str) else user_id, + "user_id": ( + ObjectId(user_id) if isinstance( + user_id, str) else user_id + ), "expires_at": expires_at, "revoked": False, "created_at": datetime.now(timezone.utc), } - ) + ) except Exception as e: - logger.error(f"Failed to create refresh token for user_id: {user_id}. Error: {str(e)}") + logger.error( + f"Failed to create refresh token for user_id: {user_id}. Error: {str(e)}" + ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to create refresh token: {str(e)}" + detail=f"Failed to create refresh token: {str(e)}", ) return refresh_token diff --git a/backend/app/expenses/service.py b/backend/app/expenses/service.py index f88fbee3..0d676e7d 100644 --- a/backend/app/expenses/service.py +++ b/backend/app/expenses/service.py @@ -1,6 +1,7 @@ +from collections import defaultdict from datetime import datetime, timedelta -from bson import ObjectId, errors -from app.database import mongodb +from typing import Any, Dict, List, Optional + from app.config import logger from app.database import mongodb from app.expenses.schemas import ( @@ -13,10 +14,10 @@ SettlementStatus, SplitType, ) -from collections import defaultdict -from typing import Dict, List, Any, Optional +from bson import ObjectId, errors from fastapi import HTTPException + class ExpenseService: def __init__(self): pass diff --git a/backend/app/groups/service.py b/backend/app/groups/service.py index 3f2c8a7a..0dcdcc96 100644 --- a/backend/app/groups/service.py +++ b/backend/app/groups/service.py @@ -2,13 +2,11 @@ import string from datetime import datetime, timezone from typing import Any, Dict, List, Optional -from fastapi import HTTPException -from app.database import get_database + from app.config import logger +from app.database import get_database from bson import ObjectId, errors -from datetime import datetime, timezone -import secrets -import string +from fastapi import HTTPException class GroupService: @@ -102,7 +100,7 @@ async def _enrich_members_with_user_details( "avatar": None, }, } - ) + ) else: # Add member without user details if userId is missing diff --git a/backend/tests/auth/test_auth_service.py b/backend/tests/auth/test_auth_service.py index d360b27e..422d6fbf 100644 --- a/backend/tests/auth/test_auth_service.py +++ b/backend/tests/auth/test_auth_service.py @@ -49,21 +49,21 @@ with appropriate HTTP response codes and messages. """ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -from fastapi import HTTPException, status -from bson import ObjectId -from jose import JWTError -from firebase_admin import auth as firebase_auth -from firebase_admin import credentials -import firebase_admin +import logging from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import firebase_admin +import pytest +from app.auth.security import create_refresh_token, get_password_hash, verify_password from app.auth.service import AuthService -from app.auth.security import get_password_hash, create_refresh_token, verify_password from bson import ObjectId from bson.errors import InvalidId -from pymongo.errors import PyMongoError, DuplicateKeyError -import logging +from fastapi import HTTPException, status +from firebase_admin import auth as firebase_auth +from firebase_admin import credentials +from jose import JWTError +from pymongo.errors import DuplicateKeyError, PyMongoError def validate_object_id(id_str: str, field_name: str = "ID") -> ObjectId: @@ -73,7 +73,8 @@ def validate_object_id(id_str: str, field_name: str = "ID") -> ObjectId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid {field_name}" ) - + + @pytest.mark.asyncio async def test_create_user_with_email_success(monkeypatch): service = AuthService() diff --git a/backend/tests/expenses/test_expense_routes.py b/backend/tests/expenses/test_expense_routes.py index fbf3cfb7..351d7c53 100644 --- a/backend/tests/expenses/test_expense_routes.py +++ b/backend/tests/expenses/test_expense_routes.py @@ -3,8 +3,8 @@ import pytest from app.expenses.schemas import ExpenseCreateRequest, ExpenseSplit from fastapi import status -from httpx import ASGITransport, AsyncClient from firebase_admin import auth as firebase_auth +from httpx import ASGITransport, AsyncClient from main import app # Adjusted import diff --git a/backend/tests/expenses/test_expense_service.py b/backend/tests/expenses/test_expense_service.py index 58e45bb1..aed9818f 100644 --- a/backend/tests/expenses/test_expense_service.py +++ b/backend/tests/expenses/test_expense_service.py @@ -1,11 +1,13 @@ -from unittest.mock import AsyncMock, MagicMock, patch +import asyncio from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + import pytest from app.expenses.schemas import ExpenseCreateRequest, ExpenseSplit, SplitType from app.expenses.service import ExpenseService -from fastapi import HTTPException from bson import ObjectId, errors -import asyncio +from fastapi import HTTPException + @pytest.fixture def expense_service(): @@ -119,7 +121,7 @@ async def test_create_expense_invalid_group(expense_service): mock_mongodb.database = mock_db mock_db.groups.find_one = AsyncMock(return_value=None) - '''# Test with invalid ObjectId format + """# Test with invalid ObjectId format with pytest.raises(ValueError, match="Group not found or user not a member"): await expense_service.create_expense( "invalid_group", expense_request, "user_a" @@ -127,17 +129,21 @@ async def test_create_expense_invalid_group(expense_service): # Test with valid ObjectId format but non-existent group with pytest.raises(ValueError, match="Group not found or user not a member"): - await expense_service.create_expense("65f1a2b3c4d5e6f7a8b9c0d0", expense_request, "user_a")''' + await expense_service.create_expense("65f1a2b3c4d5e6f7a8b9c0d0", expense_request, "user_a")""" # Updated after stricter exception handling (July 2025) # Case 1: Invalid ObjectId format with pytest.raises(HTTPException) as exc_info_1: - await expense_service.create_expense("invalid_group", expense_request, "user_a") + await expense_service.create_expense( + "invalid_group", expense_request, "user_a" + ) assert exc_info_1.value.status_code == 400 assert "Invalid group ID" in str(exc_info_1.value.detail) # Case 2: Valid ObjectId format but group not found or user not a member with pytest.raises(HTTPException) as exc_info_2: - await expense_service.create_expense(str(ObjectId()), expense_request, "user_a") + await expense_service.create_expense( + str(ObjectId()), expense_request, "user_a" + ) assert exc_info_2.value.status_code == 403 assert "not a member of this group" in str(exc_info_2.value.detail) @@ -344,7 +350,9 @@ async def test_update_expense_unauthorized(expense_service): """Test expense update by non-creator""" from app.expenses.schemas import ExpenseUpdateRequest - update_request = ExpenseUpdateRequest(description="Unauthorized Update", amount=150.0) + update_request = ExpenseUpdateRequest( + description="Unauthorized Update", amount=150.0 + ) with patch("app.expenses.service.mongodb") as mock_mongodb: mock_db = MagicMock() @@ -353,24 +361,25 @@ async def test_update_expense_unauthorized(expense_service): # Mock finding no expense (user not creator) mock_db.expenses.find_one = AsyncMock(return_value=None) - '''with pytest.raises(ValueError, match="Expense not found or not authorized to edit"): + """with pytest.raises(ValueError, match="Expense not found or not authorized to edit"): await expense_service.update_expense( "group_id", "65f1a2b3c4d5e6f7a8b9c0d1", update_request, "unauthorized_user" - )''' - #Updated test + )""" + # Updated test with pytest.raises(HTTPException) as exc_info: await expense_service.update_expense( "group_id", "65f1a2b3c4d5e6f7a8b9c0d1", update_request, - "unauthorized_user" + "unauthorized_user", ) assert exc_info.value.status_code == 403 assert "Not authorized" in str(exc_info.value.detail) + def test_expense_split_validation(): """Test expense split validation with proper assertions""" # Valid split - should not raise exception @@ -493,18 +502,17 @@ async def test_get_expense_by_id_not_found(expense_service): # Mock expense not found mock_db.expenses.find_one = AsyncMock(return_value=None) - ''' with pytest.raises(ValueError, match="Expense not found"): - await expense_service.get_expense_by_id("65f1a2b3c4d5e6f7a8b9c0d0", "65f1a2b3c4d5e6f7a8b9c0d1", "user_a")''' + """ with pytest.raises(ValueError, match="Expense not found"): + await expense_service.get_expense_by_id("65f1a2b3c4d5e6f7a8b9c0d0", "65f1a2b3c4d5e6f7a8b9c0d1", "user_a")""" # Updated after stricter exception handling (July 2025) with pytest.raises(HTTPException) as exc_info: await expense_service.get_expense_by_id( - "65f1a2b3c4d5e6f7a8b9c0d0", - "65f1a2b3c4d5e6f7a8b9c0d1", - "user_a" + "65f1a2b3c4d5e6f7a8b9c0d0", "65f1a2b3c4d5e6f7a8b9c0d1", "user_a" ) assert exc_info.value.status_code == 404 assert "Expense not found" in exc_info.value.detail + @pytest.mark.asyncio async def test_list_group_expenses_success( expense_service, mock_group_data, mock_expense_data @@ -787,17 +795,20 @@ async def test_delete_expense_not_found(expense_service): ) # Should not be called if expense not found mock_db.expenses.delete_one = AsyncMock() # Should not be called - '''with pytest.raises(ValueError, match="Expense not found or not authorized to delete"): + """with pytest.raises(ValueError, match="Expense not found or not authorized to delete"): await expense_service.delete_expense(group_id, expense_id, user_id) mock_db.settlements.delete_many.assert_not_called() - mock_db.expenses.delete_one.assert_not_called()''' + mock_db.expenses.delete_one.assert_not_called()""" # Updated after stricter exception handling (July 2025) with pytest.raises(HTTPException) as exc_info: await expense_service.delete_expense(group_id, expense_id, user_id) assert exc_info.value.status_code == 403 - assert exc_info.value.detail == "Not authorized to delete this expense or it does not exist" + assert ( + exc_info.value.detail + == "Not authorized to delete this expense or it does not exist" + ) mock_db.settlements.delete_many.assert_not_called() mock_db.expenses.delete_one.assert_not_called() @@ -928,13 +939,15 @@ async def test_create_manual_settlement_group_not_found(expense_service): mock_db.groups.find_one = AsyncMock( return_value=None) # Group not found - '''with pytest.raises(ValueError, match="Group not found or user not a member"): + """with pytest.raises(ValueError, match="Group not found or user not a member"): await expense_service.create_manual_settlement(group_id, settlement_request, user_id) - mock_db.settlements.insert_one.assert_not_called()''' - #Updated after stricter exception handling (July 2025) + mock_db.settlements.insert_one.assert_not_called()""" + # Updated after stricter exception handling (July 2025) with pytest.raises(HTTPException) as exc_info: - await expense_service.create_manual_settlement(group_id, settlement_request, user_id) + await expense_service.create_manual_settlement( + group_id, settlement_request, user_id + ) assert exc_info.value.status_code == 403 assert exc_info.value.detail == "Group not found or user not a member" @@ -1080,8 +1093,8 @@ async def test_get_group_settlements_group_not_found(expense_service): mock_db.groups.find_one = AsyncMock( return_value=None) # Group not found - '''with pytest.raises(ValueError, match="Group not found or user not a member"): - await expense_service.get_group_settlements(group_id, user_id)''' + """with pytest.raises(ValueError, match="Group not found or user not a member"): + await expense_service.get_group_settlements(group_id, user_id)""" # Updated after stricter exception handling (July 2025) with pytest.raises(HTTPException) as exc_info: await expense_service.get_group_settlements(group_id, user_id) @@ -1155,15 +1168,18 @@ async def test_get_settlement_by_id_not_found(expense_service, mock_group_data): return_value=None ) # Settlement not found - '''with pytest.raises(ValueError, match="Settlement not found"): - await expense_service.get_settlement_by_id(group_id, settlement_id_str, user_id)''' + """with pytest.raises(ValueError, match="Settlement not found"): + await expense_service.get_settlement_by_id(group_id, settlement_id_str, user_id)""" # Updated after stricter exception handling (July 2025) with pytest.raises(HTTPException) as exc_info: - await expense_service.get_settlement_by_id(group_id, settlement_id_str, user_id) + await expense_service.get_settlement_by_id( + group_id, settlement_id_str, user_id + ) assert exc_info.value.status_code == 404 assert exc_info.value.detail == "Settlement not found" + @pytest.mark.asyncio async def test_get_settlement_by_id_group_access_denied(expense_service): """Test retrieving settlement when user not member of the group""" @@ -1179,11 +1195,13 @@ async def test_get_settlement_by_id_group_access_denied(expense_service): return_value=None ) # User not in group / group doesn't exist - '''with pytest.raises(ValueError, match="Group not found or user not a member"): - await expense_service.get_settlement_by_id(group_id, settlement_id_str, user_id)''' + """with pytest.raises(ValueError, match="Group not found or user not a member"): + await expense_service.get_settlement_by_id(group_id, settlement_id_str, user_id)""" # Updated after stricter exception handling (July 2025) with pytest.raises(HTTPException) as exc_info: - await expense_service.get_settlement_by_id(group_id, settlement_id_str, user_id) + await expense_service.get_settlement_by_id( + group_id, settlement_id_str, user_id + ) assert exc_info.value.status_code == 403 assert exc_info.value.detail == "Group not found or user not a member" @@ -1280,10 +1298,10 @@ async def test_update_settlement_status_not_found(expense_service): mock_db.settlements.find_one = AsyncMock(return_value=None) - '''with pytest.raises(ValueError, match="Settlement not found"): + """with pytest.raises(ValueError, match="Settlement not found"): await expense_service.update_settlement_status( group_id, settlement_id_str, new_status - )''' + )""" # Updated after stricter exception handling (July 2025) with pytest.raises(HTTPException) as exc_info: await expense_service.update_settlement_status( @@ -1296,6 +1314,7 @@ async def test_update_settlement_status_not_found(expense_service): # Should not be called if update fails mock_db.settlements.find_one.assert_not_called() + @pytest.mark.asyncio async def test_delete_settlement_success(expense_service, mock_group_data): """Test successful deletion of a settlement""" @@ -1369,11 +1388,13 @@ async def test_delete_settlement_group_access_denied(expense_service): mock_db.groups.find_one = AsyncMock( return_value=None) # User not in group - '''with pytest.raises(ValueError, match="Group not found or user not a member"): - await expense_service.delete_settlement(group_id, settlement_id_str, user_id)''' + """with pytest.raises(ValueError, match="Group not found or user not a member"): + await expense_service.delete_settlement(group_id, settlement_id_str, user_id)""" # Updated after stricter exception handling (July 2025) with pytest.raises(HTTPException) as exc_info: - await expense_service.delete_settlement(group_id, settlement_id_str, user_id) + await expense_service.delete_settlement( + group_id, settlement_id_str, user_id + ) assert exc_info.value.status_code == 403 assert exc_info.value.detail == "Group not found or user not a member" @@ -1510,8 +1531,8 @@ async def test_get_user_balance_in_group_access_denied(expense_service): return_value=None ) # Current user not member - '''with pytest.raises(ValueError, match="Group not found or user not a member"): - await expense_service.get_user_balance_in_group(group_id, target_user_id_str, current_user_id)''' + """with pytest.raises(ValueError, match="Group not found or user not a member"): + await expense_service.get_user_balance_in_group(group_id, target_user_id_str, current_user_id)""" # Updated after stricter exception handling (July 2025) with pytest.raises(HTTPException) as exc_info: await expense_service.get_user_balance_in_group( @@ -2078,8 +2099,8 @@ async def test_get_group_analytics_group_not_found(expense_service): mock_db.groups.find_one = AsyncMock( return_value=None) # Group not found - '''with pytest.raises(ValueError, match="Group not found or user not a member"): - await expense_service.get_group_analytics(group_id, user_id)''' + """with pytest.raises(ValueError, match="Group not found or user not a member"): + await expense_service.get_group_analytics(group_id, user_id)""" # Updated after stricter exception handling (July 2025) with pytest.raises(HTTPException) as exc_info: await expense_service.get_group_analytics(group_id, user_id) diff --git a/backend/tests/groups/test_groups_service.py b/backend/tests/groups/test_groups_service.py index b7c32f32..ca95b64c 100644 --- a/backend/tests/groups/test_groups_service.py +++ b/backend/tests/groups/test_groups_service.py @@ -1,4 +1,5 @@ from unittest.mock import AsyncMock, MagicMock, patch + import pytest from app.groups.service import GroupService from bson import ObjectId diff --git a/backend/tests/user/test_user_service.py b/backend/tests/user/test_user_service.py index 60d4b85a..3d82f19c 100644 --- a/backend/tests/user/test_user_service.py +++ b/backend/tests/user/test_user_service.py @@ -294,7 +294,6 @@ async def test_delete_user_not_found(mock_db_client, mock_get_database): assert result is False - # Added Test for invalid ObjectId format for user deletion @pytest.mark.asyncio async def test_delete_user_invalid_object_id(mock_db_client, mock_get_database):