From b2b2b8a992902cb61dbf28e0799302d07fcee26e Mon Sep 17 00:00:00 2001 From: vaishnaviij Date: Tue, 29 Jul 2025 12:27:24 +0530 Subject: [PATCH 1/2] fix: standardize timestamp fields to camelCase in API responses --- backend/app/auth/schemas.py | 2 +- backend/app/auth/service.py | 56 +++++++------ backend/app/expenses/schemas.py | 30 +++---- backend/app/groups/schemas.py | 82 ++++++++++++------- backend/app/user/schemas.py | 15 ++-- backend/main.py | 1 + backend/tests/auth/test_auth_routes.py | 6 +- backend/tests/expenses/test_expense_routes.py | 1 + backend/tests/user/test_user_routes.py | 14 ++-- package-lock.json | 6 ++ 10 files changed, 126 insertions(+), 87 deletions(-) create mode 100644 package-lock.json 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..5a1d61b6 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. +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. + Args: + token: The JWT access token to verify. - Returns: - The user document corresponding to the token's subject. + Returns: + The user document corresponding to the token's subject. - Raises: - HTTPException: If the token is invalid or the user does not exist. - """ - from app.auth.security import verify_token + Raises: + HTTPException: If the token is invalid or the user does not exist. + """ + from app.auth.security import verify_token + from bson import ObjectId - payload = verify_token(token) - user_id = payload.get("sub") + payload = verify_token(token) + user_id = payload.get("sub") - if not user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" - ) + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" + ) - db = self.get_db() - user = await db.users.find_one({"_id": user_id}) + db = self.get_db() - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" - ) + 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..a11dabbc 100644 --- a/backend/app/expenses/schemas.py +++ b/backend/app/expenses/schemas.py @@ -35,11 +35,8 @@ 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 - raise ValueError( - "Split amounts must sum to total expense amount") + if abs(total_split - values["amount"]) > 0.01: + raise ValueError("Split amounts must sum to total expense amount") return v @@ -52,16 +49,13 @@ 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: - raise ValueError( - "Split amounts must sum to total expense amount") + raise ValueError("Split amounts must sum to total expense amount") return v class Config: - # Allow validation to work with partial updates validate_assignment = True @@ -70,7 +64,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 +76,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 +93,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 +110,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 +188,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..61033c77 100644 --- a/backend/app/groups/schemas.py +++ b/backend/app/groups/schemas.py @@ -5,68 +5,90 @@ 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/main.py b/backend/main.py index 3372ffb8..5547f472 100644 --- a/backend/main.py +++ b/backend/main.py @@ -133,3 +133,4 @@ async def health_check(): import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=settings.debug) + 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..4246d84a 100644 --- a/backend/tests/expenses/test_expense_routes.py +++ b/backend/tests/expenses/test_expense_routes.py @@ -1,4 +1,5 @@ from unittest.mock import AsyncMock, patch +from app.auth.dependencies import get_current_user import pytest from app.expenses.schemas import ExpenseCreateRequest, ExpenseSplit 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": {} +} From 5a8d6b745a5ec857616a5c8bce4d1aed2c604464 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 29 Jul 2025 06:59:28 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- backend/app/auth/service.py | 2 +- backend/app/expenses/schemas.py | 6 ++++-- backend/app/groups/schemas.py | 11 ++++++++--- backend/main.py | 1 - backend/tests/expenses/test_expense_routes.py | 2 +- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 5a1d61b6..4dc15d96 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -289,6 +289,7 @@ 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. @@ -329,7 +330,6 @@ async def verify_access_token(self, token: str) -> Dict[str, Any]: return user - async def request_password_reset(self, email: str) -> bool: """ Initiates a password reset process for the specified email address. diff --git a/backend/app/expenses/schemas.py b/backend/app/expenses/schemas.py index a11dabbc..cc910b68 100644 --- a/backend/app/expenses/schemas.py +++ b/backend/app/expenses/schemas.py @@ -36,7 +36,8 @@ 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: - raise ValueError("Split amounts must sum to total expense amount") + raise ValueError( + "Split amounts must sum to total expense amount") return v @@ -52,7 +53,8 @@ def validate_splits_sum(cls, v, values): 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: - raise ValueError("Split amounts must sum to total expense amount") + raise ValueError( + "Split amounts must sum to total expense amount") return v class Config: diff --git a/backend/app/groups/schemas.py b/backend/app/groups/schemas.py index 61033c77..a50baa4b 100644 --- a/backend/app/groups/schemas.py +++ b/backend/app/groups/schemas.py @@ -16,7 +16,8 @@ class GroupMemberWithDetails(BaseModel): 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 + user: Optional[dict] = Field( + default=None, alias="user") # Contains user details model_config = ConfigDict(populate_by_name=True) @@ -30,7 +31,9 @@ class GroupCreateRequest(BaseModel): class GroupUpdateRequest(BaseModel): - name: Optional[str] = Field(default=None, min_length=1, max_length=100, alias="name") + 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) @@ -44,7 +47,9 @@ class GroupResponse(BaseModel): 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") + members: Optional[List[GroupMemberWithDetails]] = Field( + default_factory=list, alias="members" + ) model_config = ConfigDict(populate_by_name=True) diff --git a/backend/main.py b/backend/main.py index 5547f472..3372ffb8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -133,4 +133,3 @@ async def health_check(): import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=settings.debug) - diff --git a/backend/tests/expenses/test_expense_routes.py b/backend/tests/expenses/test_expense_routes.py index 4246d84a..925f2f0b 100644 --- a/backend/tests/expenses/test_expense_routes.py +++ b/backend/tests/expenses/test_expense_routes.py @@ -1,7 +1,7 @@ from unittest.mock import AsyncMock, patch -from app.auth.dependencies import get_current_user 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