diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py index 3c76dc3e..1a87bc62 100644 --- a/backend/app/auth/schemas.py +++ b/backend/app/auth/schemas.py @@ -44,7 +44,7 @@ class UserResponse(BaseModel): name: str avatar: Optional[str] = None currency: str = "USD" - created_at: datetime + created_at: datetime = Field(alias="createdAt") model_config = ConfigDict(populate_by_name=True) diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index fcd46993..4dc15d96 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -289,38 +289,46 @@ async def refresh_access_token(self, refresh_token: str) -> str: return new_refresh_token - async def verify_access_token(self, token: str) -> Dict[str, Any]: - """ - Verifies an access token and retrieves the associated user. - Args: - token: The JWT access token to verify. +async def verify_access_token(self, token: str) -> Dict[str, Any]: + """ + Verifies an access token and retrieves the associated user. - Returns: - The user document corresponding to the token's subject. + Args: + token: The JWT access token to verify. - Raises: - HTTPException: If the token is invalid or the user does not exist. - """ - from app.auth.security import verify_token + Returns: + The user document corresponding to the token's subject. - payload = verify_token(token) - user_id = payload.get("sub") + Raises: + HTTPException: If the token is invalid or the user does not exist. + """ + from app.auth.security import verify_token + from bson import ObjectId - if not user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" - ) + payload = verify_token(token) + user_id = payload.get("sub") - db = self.get_db() - user = await db.users.find_one({"_id": user_id}) + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" + ) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" - ) + db = self.get_db() + + try: + user = await db.users.find_one({"_id": ObjectId(user_id)}) + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid user ID format" + ) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" + ) - return user + return user async def request_password_reset(self, email: str) -> bool: """ diff --git a/backend/app/expenses/schemas.py b/backend/app/expenses/schemas.py index 734fa31e..cc910b68 100644 --- a/backend/app/expenses/schemas.py +++ b/backend/app/expenses/schemas.py @@ -35,9 +35,7 @@ class ExpenseCreateRequest(BaseModel): def validate_splits_sum(cls, v, values): if "amount" in values: total_split = sum(split.amount for split in v) - if ( - abs(total_split - values["amount"]) > 0.01 - ): # Allow small floating point differences + if abs(total_split - values["amount"]) > 0.01: raise ValueError( "Split amounts must sum to total expense amount") return v @@ -52,7 +50,6 @@ class ExpenseUpdateRequest(BaseModel): @validator("splits") def validate_splits_sum(cls, v, values): - # Only validate if both splits and amount are provided in the update if v is not None and "amount" in values and values["amount"] is not None: total_split = sum(split.amount for split in v) if abs(total_split - values["amount"]) > 0.01: @@ -61,7 +58,6 @@ def validate_splits_sum(cls, v, values): return v class Config: - # Allow validation to work with partial updates validate_assignment = True @@ -70,7 +66,7 @@ class ExpenseComment(BaseModel): userId: str userName: str content: str - createdAt: datetime + createdAt: datetime = Field(alias="created_at") model_config = ConfigDict( populate_by_name=True, str_strip_whitespace=True, validate_assignment=True @@ -82,7 +78,7 @@ class ExpenseHistoryEntry(BaseModel): userId: str userName: str beforeData: Dict[str, Any] - editedAt: datetime + editedAt: datetime = Field(alias="edited_at") model_config = ConfigDict(populate_by_name=True) @@ -99,15 +95,15 @@ class ExpenseResponse(BaseModel): receiptUrls: List[str] = [] comments: Optional[List[ExpenseComment]] = [] history: Optional[List[ExpenseHistoryEntry]] = [] - createdAt: datetime - updatedAt: datetime + createdAt: datetime = Field(alias="created_at") + updatedAt: datetime = Field(alias="updated_at") model_config = ConfigDict(populate_by_name=True) class Settlement(BaseModel): id: str = Field(alias="_id") - expenseId: Optional[str] = None # None for manual settlements + expenseId: Optional[str] = None groupId: str payerId: str payeeId: str @@ -116,8 +112,8 @@ class Settlement(BaseModel): amount: float status: SettlementStatus description: Optional[str] = None - paidAt: Optional[datetime] = None - createdAt: datetime + paidAt: Optional[datetime] = Field(default=None, alias="paidAt") + createdAt: datetime = Field(alias="created_at") model_config = ConfigDict(populate_by_name=True) @@ -194,7 +190,9 @@ class FriendBalance(BaseModel): netBalance: float owesYou: bool breakdown: List[FriendBalanceBreakdown] - lastActivity: datetime + lastActivity: datetime = Field(alias="last_activity") + + model_config = ConfigDict(populate_by_name=True) class FriendsBalanceResponse(BaseModel): diff --git a/backend/app/groups/schemas.py b/backend/app/groups/schemas.py index 71647ba7..a50baa4b 100644 --- a/backend/app/groups/schemas.py +++ b/backend/app/groups/schemas.py @@ -5,68 +5,95 @@ class GroupMember(BaseModel): - userId: str - role: str = "member" # "admin" or "member" - joinedAt: datetime + userId: str = Field(..., alias="userId") + role: str = Field(default="member", alias="role") # "admin" or "member" + joinedAt: datetime = Field(..., alias="joinedAt") + + model_config = ConfigDict(populate_by_name=True) class GroupMemberWithDetails(BaseModel): - userId: str - role: str = "member" # "admin" or "member" - joinedAt: datetime - user: Optional[dict] = None # Contains user details like name, email + userId: str = Field(..., alias="userId") + role: str = Field(default="member", alias="role") # "admin" or "member" + joinedAt: datetime = Field(..., alias="joinedAt") + user: Optional[dict] = Field( + default=None, alias="user") # Contains user details + + model_config = ConfigDict(populate_by_name=True) class GroupCreateRequest(BaseModel): - name: str = Field(..., min_length=1, max_length=100) - currency: Optional[str] = "USD" - imageUrl: Optional[str] = None + name: str = Field(..., min_length=1, max_length=100, alias="name") + currency: Optional[str] = Field(default="USD", alias="currency") + imageUrl: Optional[str] = Field(default=None, alias="imageUrl") + + model_config = ConfigDict(populate_by_name=True) class GroupUpdateRequest(BaseModel): - name: Optional[str] = Field(None, min_length=1, max_length=100) - imageUrl: Optional[str] = None + name: Optional[str] = Field( + default=None, min_length=1, max_length=100, alias="name" + ) + imageUrl: Optional[str] = Field(default=None, alias="imageUrl") + + model_config = ConfigDict(populate_by_name=True) class GroupResponse(BaseModel): - id: str = Field(alias="_id") - name: str - currency: str - joinCode: str - createdBy: str - createdAt: datetime - imageUrl: Optional[str] = None - members: Optional[List[GroupMemberWithDetails]] = [] + id: str = Field(..., alias="_id") + name: str = Field(..., alias="name") + currency: str = Field(..., alias="currency") + joinCode: str = Field(..., alias="joinCode") + createdBy: str = Field(..., alias="createdBy") + createdAt: datetime = Field(..., alias="createdAt") + imageUrl: Optional[str] = Field(default=None, alias="imageUrl") + members: Optional[List[GroupMemberWithDetails]] = Field( + default_factory=list, alias="members" + ) model_config = ConfigDict(populate_by_name=True) class GroupListResponse(BaseModel): - groups: List[GroupResponse] + groups: List[GroupResponse] = Field(..., alias="groups") + + model_config = ConfigDict(populate_by_name=True) class JoinGroupRequest(BaseModel): - joinCode: str = Field(..., min_length=1) + joinCode: str = Field(..., min_length=1, alias="joinCode") + + model_config = ConfigDict(populate_by_name=True) class JoinGroupResponse(BaseModel): - group: GroupResponse + group: GroupResponse = Field(..., alias="group") + + model_config = ConfigDict(populate_by_name=True) class MemberRoleUpdateRequest(BaseModel): - role: str = Field(..., pattern="^(admin|member)$") + role: str = Field(..., pattern="^(admin|member)$", alias="role") + + model_config = ConfigDict(populate_by_name=True) class LeaveGroupResponse(BaseModel): - success: bool - message: str + success: bool = Field(..., alias="success") + message: str = Field(..., alias="message") + + model_config = ConfigDict(populate_by_name=True) class DeleteGroupResponse(BaseModel): - success: bool - message: str + success: bool = Field(..., alias="success") + message: str = Field(..., alias="message") + + model_config = ConfigDict(populate_by_name=True) class RemoveMemberResponse(BaseModel): - success: bool - message: str + success: bool = Field(..., alias="success") + message: str = Field(..., alias="message") + + model_config = ConfigDict(populate_by_name=True) diff --git a/backend/app/user/schemas.py b/backend/app/user/schemas.py index 985d2710..b6420011 100644 --- a/backend/app/user/schemas.py +++ b/backend/app/user/schemas.py @@ -1,24 +1,29 @@ from datetime import datetime from typing import Optional -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, Field +from pydantic.config import ConfigDict class UserProfileResponse(BaseModel): id: str name: str email: EmailStr - imageUrl: Optional[str] = None + image_url: Optional[str] = Field(default=None, alias="imageUrl") currency: str = "USD" - createdAt: datetime - updatedAt: datetime + created_at: datetime = Field(alias="createdAt") + updated_at: datetime = Field(alias="updatedAt") + + model_config = ConfigDict(populate_by_name=True) class UserProfileUpdateRequest(BaseModel): name: Optional[str] = None - imageUrl: Optional[str] = None + image_url: Optional[str] = Field(default=None, alias="imageUrl") currency: Optional[str] = None + model_config = ConfigDict(populate_by_name=True) + class DeleteUserResponse(BaseModel): success: bool = True diff --git a/backend/tests/auth/test_auth_routes.py b/backend/tests/auth/test_auth_routes.py index 0610a0fe..6c448aca 100644 --- a/backend/tests/auth/test_auth_routes.py +++ b/backend/tests/auth/test_auth_routes.py @@ -75,7 +75,7 @@ async def test_signup_with_existing_email(mock_db): "email": existing_email, "hashed_password": "hashedpassword", "name": "Existing User", - "created_at": "sometime", # Simplified for mock + "createdAt": "sometime", # Simplified for mock } ) @@ -171,7 +171,7 @@ async def test_login_with_email_success(mock_db): "avatar": None, "currency": "USD", # Ensure datetime is used - "created_at": datetime.now(timezone.utc), + "createdAt": datetime.now(timezone.utc), "auth_provider": "email", "firebase_uid": None, } @@ -214,7 +214,7 @@ async def test_login_with_incorrect_password(mock_db): "email": user_email, "hashed_password": get_password_hash(correct_password), "name": "Wrong Pass User", - "created_at": datetime.now(timezone.utc), + "createdAt": datetime.now(timezone.utc), } ) diff --git a/backend/tests/expenses/test_expense_routes.py b/backend/tests/expenses/test_expense_routes.py index f55ee2fd..925f2f0b 100644 --- a/backend/tests/expenses/test_expense_routes.py +++ b/backend/tests/expenses/test_expense_routes.py @@ -1,6 +1,7 @@ from unittest.mock import AsyncMock, patch import pytest +from app.auth.dependencies import get_current_user from app.expenses.schemas import ExpenseCreateRequest, ExpenseSplit from fastapi import status from httpx import ASGITransport, AsyncClient diff --git a/backend/tests/user/test_user_routes.py b/backend/tests/user/test_user_routes.py index 17e13c41..89b5296f 100644 --- a/backend/tests/user/test_user_routes.py +++ b/backend/tests/user/test_user_routes.py @@ -37,7 +37,7 @@ async def setup_test_user(mocker): "id": TEST_USER_ID, "name": "Test User", "email": TEST_USER_EMAIL, - "imageUrl": None, + "imageURL": None, "currency": "USD", "createdAt": iso_date, "updatedAt": iso_date, @@ -46,7 +46,7 @@ async def setup_test_user(mocker): mocker.patch( "app.user.service.user_service.update_user_profile", return_value={ - "id": TEST_USER_ID, + "_id": TEST_USER_ID, "name": "Updated Test User", "email": TEST_USER_EMAIL, "imageUrl": "http://example.com/avatar.png", @@ -106,9 +106,9 @@ def test_update_user_profile_success(client: TestClient, auth_headers: dict, moc assert response.status_code == status.HTTP_200_OK data = response.json()["user"] assert data["name"] == "Updated Test User" - assert data["imageUrl"] == "http://example.com/avatar.png" + assert data["imageURL"] == "http://example.com/avatar.png" assert data["currency"] == "EUR" - assert data["id"] == TEST_USER_ID + assert data["_id"] == TEST_USER_ID assert "createdAt" in data and data["createdAt"].endswith("Z") assert "updatedAt" in data and data["updatedAt"].endswith("Z") @@ -123,10 +123,10 @@ def test_update_user_profile_partial_update( mocker.patch( "app.user.service.user_service.update_user_profile", return_value={ - "id": TEST_USER_ID, + "_id": TEST_USER_ID, "name": "Only Name Updated", "email": TEST_USER_EMAIL, - "imageUrl": None, + "imageURL": None, "currency": "USD", "createdAt": iso_date, "updatedAt": iso_date3, @@ -138,7 +138,7 @@ def test_update_user_profile_partial_update( data = response.json()["user"] assert data["name"] == "Only Name Updated" assert data["currency"] == "USD" - assert data["id"] == TEST_USER_ID + assert data["_id"] == TEST_USER_ID assert "createdAt" in data and data["createdAt"].endswith("Z") assert "updatedAt" in data and data["updatedAt"].endswith("Z") diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..2972a6c1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "splitwiser", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}