diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py index bf2d5b14..8dbb7ee7 100644 --- a/backend/app/auth/routes.py +++ b/backend/app/auth/routes.py @@ -6,12 +6,43 @@ UserResponse, ErrorResponse ) from app.auth.service import auth_service -from app.auth.security import create_access_token +from app.auth.security import create_access_token, oauth2_scheme # Import oauth2_scheme +from fastapi.security import OAuth2PasswordRequestForm # Import OAuth2PasswordRequestForm from datetime import timedelta from app.config import settings router = APIRouter(prefix="/auth", tags=["Authentication"]) +@router.post("/token", response_model=TokenResponse, include_in_schema=False) # include_in_schema=False to hide from docs if desired, or True to show +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): + """ + OAuth2 compatible token login, get an access token for future requests. + This endpoint is used by Swagger UI for authorization. + It expects username (email) and password in form-data. + """ + try: + # Note: OAuth2PasswordRequestForm uses 'username' field for the user identifier. + # We'll treat it as email here. + result = await auth_service.authenticate_user_with_email( + email=form_data.username, # form_data.username is the email + password=form_data.password + ) + + access_token = create_access_token( + data={"sub": str(result["user"]["_id"])}, + expires_delta=timedelta(minutes=settings.access_token_expire_minutes) + ) + + return TokenResponse(access_token=access_token, token_type="bearer") + except HTTPException: + raise + except Exception as e: + # It's good practice to log the exception here + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Authentication failed: {str(e)}" + ) + @router.post("/signup/email", response_model=AuthResponse) async def signup_with_email(request: EmailSignupRequest): """ diff --git a/backend/app/auth/security.py b/backend/app/auth/security.py index 97849cee..53f1475e 100644 --- a/backend/app/auth/security.py +++ b/backend/app/auth/security.py @@ -1,8 +1,9 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional, Dict, Any from jose import JWTError, jwt from passlib.context import CryptContext -from fastapi import HTTPException, status +from fastapi import HTTPException, status, Depends +from fastapi.security import OAuth2PasswordBearer from app.config import settings import secrets @@ -13,6 +14,8 @@ # Fallback for bcrypt version compatibility issues pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=12) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") # Updated tokenUrl + def verify_password(plain_password: str, hashed_password: str) -> bool: """ Verifies whether a plaintext password matches a given hashed password. @@ -53,9 +56,9 @@ def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] """ to_encode = data.copy() if expires_delta: - expire = datetime.utcnow() + expires_delta + expire = datetime.now(timezone.utc) + expires_delta else: - expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes) + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) to_encode.update({"exp": expire, "type": "access"}) encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) @@ -95,3 +98,22 @@ def generate_reset_token() -> str: A random 32-byte URL-safe string suitable for use as a password reset token. """ return secrets.token_urlsafe(32) + +def get_current_user(token: str = Depends(oauth2_scheme)) -> Dict[str, Any]: + """ + Retrieves the current user based on the provided JWT token using centralized verification. + + Args: + token: The JWT token from which to extract the user information. + + Returns: + A dictionary containing the current user's information. + + Raises: + HTTPException: If the token is invalid or user information cannot be extracted. + """ + payload = verify_token(token) # Centralized JWT validation and error handling + user_id = payload.get("sub") + if user_id is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload") + return {"_id": user_id} diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 2e5778ce..63a96e10 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional, Dict, Any from pymongo.errors import DuplicateKeyError from bson import ObjectId @@ -95,7 +95,7 @@ async def create_user_with_email(self, email: str, password: str, name: str) -> "name": name, "avatar": None, "currency": "USD", - "created_at": datetime.utcnow(), + "created_at": datetime.now(timezone.utc), "auth_provider": "email", "firebase_uid": None } @@ -198,7 +198,7 @@ async def authenticate_with_google(self, id_token: str) -> Dict[str, Any]: "name": name, "avatar": picture, "currency": "USD", - "created_at": datetime.utcnow(), + "created_at": datetime.now(timezone.utc), "auth_provider": "google", "firebase_uid": firebase_uid, "hashed_password": None @@ -245,7 +245,7 @@ async def refresh_access_token(self, refresh_token: str) -> str: token_record = await db.refresh_tokens.find_one({ "token": refresh_token, "revoked": False, - "expires_at": {"$gt": datetime.utcnow()} + "expires_at": {"$gt": datetime.now(timezone.utc)} }) if not token_record: @@ -322,7 +322,7 @@ async def request_password_reset(self, email: str) -> bool: # Generate reset token reset_token = generate_reset_token() - reset_expires = datetime.utcnow() + timedelta(hours=1) # 1 hour expiry + reset_expires = datetime.now(timezone.utc) + timedelta(hours=1) # 1 hour expiry # Store reset token await db.password_resets.insert_one({ @@ -362,7 +362,7 @@ async def confirm_password_reset(self, reset_token: str, new_password: str) -> b reset_record = await db.password_resets.find_one({ "token": reset_token, "used": False, - "expires_at": {"$gt": datetime.utcnow()} + "expires_at": {"$gt": datetime.now(timezone.utc)} }) if not reset_record: @@ -406,14 +406,14 @@ async def _create_refresh_token_record(self, user_id: str) -> str: db = self.get_db() refresh_token = create_refresh_token() - expires_at = datetime.utcnow() + timedelta(days=settings.refresh_token_expire_days) + 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.utcnow() + "created_at": datetime.now(timezone.utc) }) return refresh_token diff --git a/backend/app/user/routes.py b/backend/app/user/routes.py new file mode 100644 index 00000000..439b76fe --- /dev/null +++ b/backend/app/user/routes.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from app.user.schemas import UserProfileResponse, UserProfileUpdateRequest, DeleteUserResponse +from app.user.service import user_service +from app.auth.security import get_current_user +from typing import Dict, Any + +router = APIRouter(prefix="/users", tags=["User"]) + +@router.get("/me", response_model=UserProfileResponse) +async def get_current_user_profile(current_user: Dict[str, Any] = Depends(get_current_user)): + user = await user_service.get_user_by_id(current_user["_id"]) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + +@router.patch("/me", response_model=Dict[str, Any]) +async def update_user_profile( + updates: UserProfileUpdateRequest, + current_user: Dict[str, Any] = Depends(get_current_user) +): + update_data = updates.model_dump(exclude_unset=True) + if not update_data: + raise HTTPException(status_code=400, detail="No update fields provided.") + updated_user = await user_service.update_user_profile(current_user["_id"], update_data) + if not updated_user: + raise HTTPException(status_code=404, detail="User not found") + return {"user": updated_user} + +@router.delete("/me", response_model=DeleteUserResponse) +async def delete_user_account(current_user: Dict[str, Any] = Depends(get_current_user)): + deleted = await user_service.delete_user(current_user["_id"]) + if not deleted: + raise HTTPException(status_code=404, detail="User not found") + return DeleteUserResponse(success=True, message="User account scheduled for deletion.") diff --git a/backend/app/user/schemas.py b/backend/app/user/schemas.py new file mode 100644 index 00000000..9b1e8d2b --- /dev/null +++ b/backend/app/user/schemas.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, EmailStr, Field +from typing import Optional +from datetime import datetime + +class UserProfileResponse(BaseModel): + id: str = Field(alias="_id") + name: str + email: EmailStr + imageUrl: Optional[str] = Field(default=None, alias="avatar") + currency: str = "USD" + createdAt: datetime + updatedAt: datetime + + model_config = {"populate_by_name": True} + +class UserProfileUpdateRequest(BaseModel): + name: Optional[str] = None + imageUrl: Optional[str] = None + currency: Optional[str] = None + +class DeleteUserResponse(BaseModel): + success: bool = True + message: Optional[str] = None diff --git a/backend/app/user/service.py b/backend/app/user/service.py new file mode 100644 index 00000000..b773bc5a --- /dev/null +++ b/backend/app/user/service.py @@ -0,0 +1,63 @@ +from fastapi import HTTPException, status, Depends +from app.database import get_database +from bson import ObjectId +from datetime import datetime, timezone +from typing import Optional, Dict, Any + +class UserService: + def __init__(self): + pass + + def get_db(self): + return get_database() + + def transform_user_document(self, user: dict) -> dict: + if not user: + return None + try: + user_id = str(user["_id"]) + except Exception: + return None # Handle invalid ObjectId gracefully + return { + "_id": user_id, + "name": user.get("name"), + "email": user.get("email"), + "avatar": user.get("imageUrl") or user.get("avatar"), + "currency": user.get("currency", "USD"), + "createdAt": user.get("created_at"), + "updatedAt": user.get("updated_at") or user.get("created_at"), + } + + async def get_user_by_id(self, user_id: str) -> Optional[dict]: + db = self.get_db() + try: + obj_id = ObjectId(user_id) + except Exception: + return None # Handle invalid ObjectId gracefully + user = await db.users.find_one({"_id": obj_id}) + return self.transform_user_document(user) + + async def update_user_profile(self, user_id: str, updates: dict) -> Optional[dict]: + db = self.get_db() + try: + obj_id = ObjectId(user_id) + except Exception: + return None # Handle invalid ObjectId gracefully + updates["updated_at"] = datetime.now(timezone.utc) + result = await db.users.find_one_and_update( + {"_id": obj_id}, + {"$set": updates}, + return_document=True + ) + return self.transform_user_document(result) + + async def delete_user(self, user_id: str) -> bool: + db = self.get_db() + try: + obj_id = ObjectId(user_id) + except Exception: + return False # Handle invalid ObjectId gracefully + result = await db.users.delete_one({"_id": obj_id}) + return result.deleted_count == 1 + +user_service = UserService() diff --git a/backend/main.py b/backend/main.py index 68a36afd..89c88cf4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,16 +1,31 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import Response +from contextlib import asynccontextmanager from app.database import connect_to_mongo, close_mongo_connection from app.auth.routes import router as auth_router +from app.user.routes import router as user_router from app.config import settings +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + print("Lifespan: Connecting to MongoDB...") + await connect_to_mongo() + print("Lifespan: MongoDB connected.") + yield + # Shutdown + print("Lifespan: Closing MongoDB connection...") + await close_mongo_connection() + print("Lifespan: MongoDB connection closed.") + app = FastAPI( title="Splitwiser API", description="Backend API for Splitwiser expense tracking application", version="1.0.0", docs_url="/docs", - redoc_url="/redoc" + redoc_url="/redoc", + lifespan=lifespan ) # CORS middleware - Enhanced configuration for production @@ -74,21 +89,6 @@ async def options_handler(request: Request, path: str): return response -# Database events -@app.on_event("startup") -async def startup_event(): - """ - Initializes the MongoDB connection when the application starts. - """ - await connect_to_mongo() - -@app.on_event("shutdown") -async def shutdown_event(): - """ - Closes the MongoDB connection when the application shuts down. - """ - await close_mongo_connection() - # Health check @app.get("/health") async def health_check(): @@ -101,6 +101,7 @@ async def health_check(): # Include routers app.include_router(auth_router) +app.include_router(user_router) if __name__ == "__main__": import uvicorn diff --git a/backend/requirements.txt b/backend/requirements.txt index 61bf6da1..5a180f37 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -17,3 +17,4 @@ httpx mongomock-motor pytest-env pytest-cov +pytest-mock diff --git a/backend/tests/auth/test_auth_routes.py b/backend/tests/auth/test_auth_routes.py index a2f47b30..72aa417e 100644 --- a/backend/tests/auth/test_auth_routes.py +++ b/backend/tests/auth/test_auth_routes.py @@ -4,7 +4,7 @@ from main import app # Assuming your FastAPI app instance is here from app.config import settings # To potentially override settings if needed, or check values from app.auth.security import verify_password, get_password_hash # For checking hashed password if necessary -from datetime import datetime +from datetime import datetime, timezone from bson import ObjectId # It's good practice to set a specific test secret key if not relying on external env vars @@ -135,7 +135,7 @@ async def test_login_with_email_success(mock_db): "name": "Login User", "avatar": None, "currency": "USD", - "created_at": datetime.utcnow(), # Ensure datetime is used + "created_at": datetime.now(timezone.utc), # Ensure datetime is used "auth_provider": "email", "firebase_uid": None }) @@ -173,7 +173,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.utcnow() + "created_at": datetime.now(timezone.utc) }) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7d369a85..6f8c6f39 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -3,6 +3,14 @@ from unittest.mock import patch, MagicMock import firebase_admin # Added import os # Added +import sys # Added +from pathlib import Path # Added + +# Add project root to sys.path to allow imports from app and main +# This assumes conftest.py is in backend/tests/ +project_root = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(project_root)) + from mongomock_motor import AsyncMongoMockClient @pytest.fixture(scope="session", autouse=True) diff --git a/backend/tests/user/test_user_routes.py b/backend/tests/user/test_user_routes.py new file mode 100644 index 00000000..86640221 --- /dev/null +++ b/backend/tests/user/test_user_routes.py @@ -0,0 +1,160 @@ +import pytest +from fastapi.testclient import TestClient # Changed from httpx import AsyncClient +from fastapi import status +from main import app # Corrected: Assuming your FastAPI app instance is named 'app' in main.py +from app.auth.security import create_access_token # Helper to create tokens for testing +from datetime import datetime, timedelta # Added datetime + +# Sample user data for testing +TEST_USER_ID = "60c72b2f9b1e8a3f9c8b4567" # Example ObjectId string +TEST_USER_EMAIL = "testuser@example.com" + +@pytest.fixture(scope="module") +def client(): # Changed to synchronous fixture + with TestClient(app) as c: # Changed to TestClient + yield c + +@pytest.fixture(scope="module") +def auth_headers(): + token = create_access_token( + data={"sub": TEST_USER_EMAIL, "_id": TEST_USER_ID}, + expires_delta=timedelta(minutes=15) + ) + return {"Authorization": f"Bearer {token}"} + +# Placeholder for database setup/teardown if needed, e.g., creating a test user +@pytest.fixture(autouse=True, scope="function") +async def setup_test_user(mocker): + # Mock the user service to avoid actual database calls initially + # This allows focusing on route logic. + # More specific mocks will be needed per test. + mocker.patch("app.user.service.user_service.get_user_by_id", return_value={ + "_id": TEST_USER_ID, + "name": "Test User", + "email": TEST_USER_EMAIL, + "avatar": None, + "currency": "USD", + "createdAt": datetime.fromisoformat("2023-01-01T00:00:00"), # Changed to camelCase + "updatedAt": datetime.fromisoformat("2023-01-01T00:00:00") # Changed to camelCase + }) + mocker.patch("app.user.service.user_service.update_user_profile", return_value={ + "_id": TEST_USER_ID, + "name": "Updated Test User", + "email": TEST_USER_EMAIL, + "avatar": "http://example.com/avatar.png", + "currency": "EUR", + "createdAt": datetime.fromisoformat("2023-01-01T00:00:00"), # Changed to camelCase + "updatedAt": datetime.fromisoformat("2023-01-02T00:00:00") # Changed to camelCase + }) + mocker.patch("app.user.service.user_service.delete_user", return_value=True) + + # If you have a real test database, you'd create a user here. + # For now, we rely on mocking the service layer. + # Example: + # from app.database import get_database + # db = get_database() + # await db.users.insert_one({ + # "_id": ObjectId(TEST_USER_ID), + # "email": TEST_USER_EMAIL, + # "name": "Test User", + # "hashed_password": "fakehashedpassword", # Add other necessary fields + # "created_at": datetime.utcnow(), + # "updated_at": datetime.utcnow() + # }) + yield + # Teardown: remove the test user + # Example: + # await db.users.delete_one({"_id": ObjectId(TEST_USER_ID)}) + +# --- Tests for GET /users/me --- + +def test_get_current_user_profile_success(client: TestClient, auth_headers: dict, mocker): # Changed AsyncClient, removed async + """Test successful retrieval of current user's profile.""" + # The setup_test_user fixture already mocks get_user_by_id + response = client.get("/users/me", headers=auth_headers) # removed await + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["_id"] == TEST_USER_ID + assert data["email"] == TEST_USER_EMAIL + assert "name" in data + assert "currency" in data + +def test_get_current_user_profile_not_found(client: TestClient, auth_headers: dict, mocker): # Changed AsyncClient, removed async + """Test retrieval when user is not found in service layer.""" + mocker.patch("app.user.service.user_service.get_user_by_id", return_value=None) + + response = client.get("/users/me", headers=auth_headers) # removed await + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json() == {"detail": "User not found"} + +# --- Tests for PATCH /users/me --- + +def test_update_user_profile_success(client: TestClient, auth_headers: dict, mocker): # Changed AsyncClient, removed async + """Test successful update of user profile.""" + update_payload = { + "name": "Updated Test User", + "imageUrl": "http://example.com/avatar.png", + "currency": "EUR" + } + # The setup_test_user fixture already mocks update_user_profile + response = client.patch("/users/me", headers=auth_headers, json=update_payload) # removed await + assert response.status_code == status.HTTP_200_OK + data = response.json()["user"] # Response is {"user": updated_user_data} + assert data["name"] == "Updated Test User" + assert data["avatar"] == "http://example.com/avatar.png" # Note: schema uses imageUrl, service uses avatar + assert data["currency"] == "EUR" + assert data["_id"] == TEST_USER_ID + +def test_update_user_profile_partial_update(client: TestClient, auth_headers: dict, mocker): # Changed AsyncClient, removed async + """Test updating only one field of the user profile.""" + update_payload = {"name": "Only Name Updated"} + + # Specific mock for this test case if needed, or ensure global mock handles partials + mocker.patch("app.user.service.user_service.update_user_profile", return_value={ + "_id": TEST_USER_ID, "name": "Only Name Updated", "email": TEST_USER_EMAIL, + "avatar": None, "currency": "USD", # Assuming other fields remain unchanged + "createdAt": datetime.fromisoformat("2023-01-01T00:00:00"), # Changed to camelCase and datetime + "updatedAt": datetime.fromisoformat("2023-01-03T00:00:00") # Changed to camelCase and datetime + }) + + response = client.patch("/users/me", headers=auth_headers, json=update_payload) # removed await + assert response.status_code == status.HTTP_200_OK + data = response.json()["user"] + assert data["name"] == "Only Name Updated" + assert data["currency"] == "USD" # Assuming currency wasn't updated + +def test_update_user_profile_no_fields(client: TestClient, auth_headers: dict): # Changed AsyncClient, removed async + """Test updating profile with no fields, expecting a 400 error.""" + response = client.patch("/users/me", headers=auth_headers, json={}) # removed await + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == {"detail": "No update fields provided."} + +def test_update_user_profile_user_not_found(client: TestClient, auth_headers: dict, mocker): # Changed AsyncClient, removed async + """Test updating profile when user is not found by the service.""" + mocker.patch("app.user.service.user_service.update_user_profile", return_value=None) + update_payload = {"name": "Attempted Update"} + response = client.patch("/users/me", headers=auth_headers, json=update_payload) # removed await + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json() == {"detail": "User not found"} + +# --- Tests for DELETE /users/me --- + +def test_delete_user_account_success(client: TestClient, auth_headers: dict, mocker): # Changed AsyncClient, removed async + """Test successful deletion of a user account.""" + # The setup_test_user fixture already mocks delete_user to return True + response = client.delete("/users/me", headers=auth_headers) # removed await + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["success"] is True + assert data["message"] == "User account scheduled for deletion." + +def test_delete_user_account_not_found(client: TestClient, auth_headers: dict, mocker): # Changed AsyncClient, removed async + """Test deleting a user account when the user is not found by the service.""" + mocker.patch("app.user.service.user_service.delete_user", return_value=False) + response = client.delete("/users/me", headers=auth_headers) # removed await + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json() == {"detail": "User not found"} + +# All route tests are in place, removing the placeholder +# def test_placeholder(): +# assert True diff --git a/backend/tests/user/test_user_service.py b/backend/tests/user/test_user_service.py new file mode 100644 index 00000000..2d6e5b99 --- /dev/null +++ b/backend/tests/user/test_user_service.py @@ -0,0 +1,195 @@ +import pytest +from app.user.service import UserService +from app.database import get_database # To mock the database dependency +from bson import ObjectId +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock # For mocking async methods and db client + +# Initialize UserService instance for testing +user_service = UserService() + +# --- Fixtures --- + +@pytest.fixture +def mock_db_client(): + """Fixture to create a mock database client with an async users collection.""" + db_client = MagicMock() + db_client.users = AsyncMock() # Mock the 'users' collection + return db_client + +@pytest.fixture(autouse=True) +def mock_get_database(mocker, mock_db_client): + """Autouse fixture to mock get_database and return the mock_db_client.""" + return mocker.patch("app.user.service.get_database", return_value=mock_db_client) + + +# --- Test Data --- + +TEST_OBJECT_ID_STR = "60c72b2f9b1e8a3f9c8b4567" +TEST_OBJECT_ID = ObjectId(TEST_OBJECT_ID_STR) +NOW = datetime.now(timezone.utc) + +RAW_USER_FROM_DB = { + "_id": TEST_OBJECT_ID, + "name": "Test User", + "email": "test@example.com", + "avatar": "http://example.com/avatar.jpg", + "currency": "EUR", + "created_at": NOW, + "updated_at": NOW, +} + +TRANSFORMED_USER_EXPECTED = { + "_id": TEST_OBJECT_ID_STR, + "name": "Test User", + "email": "test@example.com", + "avatar": "http://example.com/avatar.jpg", + "currency": "EUR", + "createdAt": NOW, + "updatedAt": NOW, +} + +# --- Tests for transform_user_document --- + +def test_transform_user_document_all_fields(): + transformed = user_service.transform_user_document(RAW_USER_FROM_DB) + assert transformed == TRANSFORMED_USER_EXPECTED + +def test_transform_user_document_missing_optional_fields(): + raw_user_minimal = { + "_id": TEST_OBJECT_ID, + "name": "Minimal User", + "email": "minimal@example.com", + "created_at": NOW, + } + expected_transformed_minimal = { + "_id": TEST_OBJECT_ID_STR, + "name": "Minimal User", + "email": "minimal@example.com", + "avatar": None, # Expect None if not present + "currency": "USD", # Expect default if not present + "createdAt": NOW, + "updatedAt": NOW, # Expect createdAt if updatedAt not present + } + transformed = user_service.transform_user_document(raw_user_minimal) + assert transformed == expected_transformed_minimal + +def test_transform_user_document_with_updated_at_different_from_created_at(): + later_time = datetime.now(timezone.utc) + raw_user_updated = { + "_id": TEST_OBJECT_ID, + "name": "Updated User", + "email": "updated@example.com", + "created_at": NOW, + "updated_at": later_time + } + expected_transformed_updated = { + "_id": TEST_OBJECT_ID_STR, + "name": "Updated User", + "email": "updated@example.com", + "avatar": None, + "currency": "USD", + "createdAt": NOW, + "updatedAt": later_time, + } + transformed = user_service.transform_user_document(raw_user_updated) + assert transformed == expected_transformed_updated + +def test_transform_user_document_none_input(): + assert user_service.transform_user_document(None) is None + +# --- Tests for get_user_by_id --- + +@pytest.mark.asyncio +async def test_get_user_by_id_found(mock_db_client, mock_get_database): + mock_db_client.users.find_one.return_value = RAW_USER_FROM_DB + + user = await user_service.get_user_by_id(TEST_OBJECT_ID_STR) + + mock_db_client.users.find_one.assert_called_once_with({"_id": TEST_OBJECT_ID}) + assert user == TRANSFORMED_USER_EXPECTED + +@pytest.mark.asyncio +async def test_get_user_by_id_not_found(mock_db_client, mock_get_database): + mock_db_client.users.find_one.return_value = None + + user = await user_service.get_user_by_id(TEST_OBJECT_ID_STR) + + mock_db_client.users.find_one.assert_called_once_with({"_id": TEST_OBJECT_ID}) + assert user is None + +# --- Tests for update_user_profile --- + +@pytest.mark.asyncio +async def test_update_user_profile_success(mock_db_client, mock_get_database): + update_data = {"name": "New Name", "currency": "CAD"} + + # The user document that find_one_and_update would return + updated_user_doc_from_db = RAW_USER_FROM_DB.copy() + updated_user_doc_from_db.update(update_data) + # updated_at will be set by the service method, so we don't put it in update_data + # but the mock return value should reflect it. + # For simplicity, we'll check its existence rather than exact value in this mock. + + mock_db_client.users.find_one_and_update.return_value = updated_user_doc_from_db + + # Expected transformed output + expected_transformed = user_service.transform_user_document(updated_user_doc_from_db) + + updated_user = await user_service.update_user_profile(TEST_OBJECT_ID_STR, update_data) + + args, kwargs = mock_db_client.users.find_one_and_update.call_args + assert args[0] == {"_id": TEST_OBJECT_ID} + assert "$set" in args[1] + assert args[1]["$set"]["name"] == "New Name" + assert args[1]["$set"]["currency"] == "CAD" + assert "updated_at" in args[1]["$set"] # Check that updated_at was added + assert kwargs["return_document"] is True # from pymongo import ReturnDocument (True means ReturnDocument.AFTER) + + assert updated_user is not None + assert updated_user["name"] == "New Name" + assert updated_user["currency"] == "CAD" + assert updated_user["_id"] == TEST_OBJECT_ID_STR + # Ensure 'updated_at' in the result is more recent or equal to original if not updated by mock + assert updated_user["updatedAt"] >= TRANSFORMED_USER_EXPECTED["updatedAt"] + + +@pytest.mark.asyncio +async def test_update_user_profile_user_not_found(mock_db_client, mock_get_database): + mock_db_client.users.find_one_and_update.return_value = None # Simulate user not found + update_data = {"name": "New Name"} + NON_EXISTENT_VALID_OID = "123456789012345678901234" + + updated_user = await user_service.update_user_profile(NON_EXISTENT_VALID_OID, update_data) + + args, kwargs = mock_db_client.users.find_one_and_update.call_args + assert args[0] == {"_id": ObjectId(NON_EXISTENT_VALID_OID)} + assert "$set" in args[1] + assert args[1]["$set"]["name"] == "New Name" + assert "updated_at" in args[1]["$set"] + assert kwargs["return_document"] is True + assert updated_user is None + +# --- Tests for delete_user --- + +@pytest.mark.asyncio +async def test_delete_user_success(mock_db_client, mock_get_database): + mock_delete_result = MagicMock() + mock_delete_result.deleted_count = 1 + mock_db_client.users.delete_one.return_value = mock_delete_result + + result = await user_service.delete_user(TEST_OBJECT_ID_STR) + + mock_db_client.users.delete_one.assert_called_once_with({"_id": TEST_OBJECT_ID}) + assert result is True + +@pytest.mark.asyncio +async def test_delete_user_not_found(mock_db_client, mock_get_database): + mock_delete_result = MagicMock() + mock_delete_result.deleted_count = 0 + mock_db_client.users.delete_one.return_value = mock_delete_result + + result = await user_service.delete_user(TEST_OBJECT_ID_STR) + + mock_db_client.users.delete_one.assert_called_once_with({"_id": TEST_OBJECT_ID}) + assert result is False