From 498eb8eeca4c071d085897d0f9922a9f38db2ba6 Mon Sep 17 00:00:00 2001 From: matia Date: Sat, 31 May 2025 23:27:44 -0700 Subject: [PATCH 1/4] implemented crud operations for users --- backend/app/models/User.py | 3 +- backend/app/routes/user.py | 67 ++++++++++++- backend/app/schemas/user.py | 52 ++++++++++- .../services/implementations/user_service.py | 93 +++++++++++++++++-- 4 files changed, 204 insertions(+), 11 deletions(-) diff --git a/backend/app/models/User.py b/backend/app/models/User.py index bfe2ef7c..c062b463 100644 --- a/backend/app/models/User.py +++ b/backend/app/models/User.py @@ -1,6 +1,6 @@ import uuid -from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy import Boolean, Column, ForeignKey, Integer, String from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -15,5 +15,6 @@ class User(Base): email = Column(String(120), unique=True, nullable=False) role_id = Column(Integer, ForeignKey("roles.id"), nullable=False) auth_id = Column(String, nullable=False) + approved = Column(Boolean, default=False) role = relationship("Role") diff --git a/backend/app/routes/user.py b/backend/app/routes/user.py index 797248b2..194abef1 100644 --- a/backend/app/routes/user.py +++ b/backend/app/routes/user.py @@ -1,7 +1,10 @@ +from typing import List +from uuid import UUID + from fastapi import APIRouter, Depends, HTTPException from app.middleware.auth import has_roles -from app.schemas.user import UserCreateRequest, UserCreateResponse, UserRole +from app.schemas.user import UserCreateRequest, UserCreateResponse, UserListResponse, UserResponse, UserRole, UserUpdateRequest from app.services.implementations.user_service import UserService from app.utilities.service_utils import get_user_service @@ -28,3 +31,65 @@ async def create_user( raise http_ex except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +# admin only get all users +@router.get("/", response_model=UserListResponse) +async def get_users( + user_service: UserService = Depends(get_user_service), + authorized: bool = has_roles([UserRole.ADMIN]), +): + try: + users = user_service.get_users() + return UserListResponse(users=users, total=len(users)) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# admin only get user by ID +@router.get("/{user_id}", response_model=UserResponse) +async def get_user( + user_id: str, + user_service: UserService = Depends(get_user_service), + authorized: bool = has_roles([UserRole.ADMIN]), +): + try: + return user_service.get_user_by_id(user_id) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# admin only update user (mainly for approvals) +@router.put("/{user_id}", response_model=UserResponse) +async def update_user( + user_id: str, + user_update: UserUpdateRequest, + user_service: UserService = Depends(get_user_service), + authorized: bool = Depends(has_roles([UserRole.ADMIN])), +): + try: + return user_service.update_user_by_id(user_id, user_update) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# admin only delete user +@router.delete("/{user_id}") +async def delete_user( + user_id: str, + user_service: UserService = Depends(get_user_service), + authorized: bool = has_roles([UserRole.ADMIN]), +): + try: + user_service.delete_user_by_id(user_id) + return {"message": "User deleted successfully"} + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index b2c07191..91fb9468 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -4,7 +4,7 @@ """ from enum import Enum -from typing import Optional +from typing import List, Optional from uuid import UUID from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator @@ -73,6 +73,18 @@ def validate_password(cls, password: Optional[str], info): return password +class UserUpdateRequest(BaseModel): + """ + Request schema for user updates, all fields optional + """ + + first_name: Optional[str] = Field(None, min_length=1, max_length=50) + last_name: Optional[str] = Field(None, min_length=1, max_length=50) + email: Optional[EmailStr] = None + role: Optional[UserRole] = None + approved: Optional[bool] = None + + class UserCreateResponse(BaseModel): """ Response schema for user creation, maps directly from ORM User object. @@ -84,6 +96,44 @@ class UserCreateResponse(BaseModel): email: EmailStr role_id: int auth_id: str + approved: bool # from_attributes enables automatic mapping from SQLAlchemy model to Pydantic model model_config = ConfigDict(from_attributes=True) + + +class UserResponse(BaseModel): + """ + Response schema for user data including role information + """ + + id: UUID + first_name: str + last_name: str + email: EmailStr + role_id: int + auth_id: str + approved: bool + role: "RoleResponse" + + model_config = ConfigDict(from_attributes=True) + + +class RoleResponse(BaseModel): + """ + Response schema for role data + """ + + id: int + name: str + + model_config = ConfigDict(from_attributes=True) + + +class UserListResponse(BaseModel): + """ + Response schema for listing users + """ + + users: List[UserResponse] + total: int diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index 391cbf54..0bb0cbcf 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -1,4 +1,6 @@ import logging +from typing import List +from uuid import UUID import firebase_admin.auth from fastapi import HTTPException @@ -10,7 +12,9 @@ SignUpMethod, UserCreateRequest, UserCreateResponse, + UserResponse, UserRole, + UserUpdateRequest, ) from app.utilities.constants import LOGGER_NAME @@ -79,10 +83,38 @@ async def create_user(self, user: UserCreateRequest) -> UserCreateResponse: raise HTTPException(status_code=500, detail=str(e)) def delete_user_by_email(self, email: str): - pass + try: + db_user = self.db.query(User).filter(User.email == email).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + self.db.delete(db_user) + self.db.commit() + except HTTPException: + raise + except Exception as e: + self.db.rollback() + self.logger.error(f"Error deleting user with email {email}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + def delete_user_by_id(self, user_id: str): - pass + try: + db_user = self.db.query(User).filter(User.id == UUID(user_id)).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + self.db.delete(db_user) + self.db.commit() + + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID format") + except HTTPException: + raise + except Exception as e: + self.db.rollback() + self.logger.error(f"Error deleting user {user_id}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) def get_user_id_by_auth_id(self, auth_id: str) -> str: """Get user ID for a user by their Firebase auth_id""" @@ -97,8 +129,19 @@ def get_user_by_email(self, email: str): raise ValueError(f"User with email {email} not found") return user - def get_user_by_id(self, user_id: str): - pass + def get_user_by_id(self, user_id: str) -> UserResponse: + try: + user = self.db.query(User).join(Role).filter(User.id == UUID(user_id)).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return UserResponse.model_validate(user) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID format") + except HTTPException: + raise + except Exception as e: + self.logger.error(f"Error retrieving user {user_id}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) def get_auth_id_by_user_id(self, user_id: str) -> str: """Get Firebase auth_id for a user""" @@ -114,8 +157,42 @@ def get_user_role_by_auth_id(self, auth_id: str) -> str: raise ValueError(f"User with auth_id {auth_id} not found") return user.role.name - def get_users(self): - pass + def get_users(self) -> List[UserResponse]: + try: + users = self.db.query(User).join(Role).all() + return [UserResponse.model_validate(user) for user in users] + except Exception as e: + self.logger.error(f"Error retrieving users: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + def update_user_by_id(self, user_id: str, user_update: UserUpdateRequest) -> UserResponse: + try: + db_user = self.db.query(User).filter(User.id == UUID(user_id)).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + # update provided fields only + update_data = user_update.model_dump(exclude_unset=True) + + # handle role conversion if role is being updated + if "role" in update_data: + update_data["role_id"] = UserRole.to_role_id(update_data.pop("role")) - def update_user_by_id(self, user_id: str, user): - pass + for field, value in update_data.items(): + setattr(db_user, field, value) + + self.db.commit() + self.db.refresh(db_user) + + # return user with role information + updated_user = self.db.query(User).join(Role).filter(User.id == UUID(user_id)).first() + return UserResponse.model_validate(updated_user) + + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID format") + except HTTPException: + raise + except Exception as e: + self.db.rollback() + self.logger.error(f"Error updating user {user_id}: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) From 6f5c77131438778a6dbf87eee056c6e85088b325 Mon Sep 17 00:00:00 2001 From: matia Date: Sun, 1 Jun 2025 11:44:18 -0700 Subject: [PATCH 2/4] code formatting --- backend/app/middleware/auth.py | 1 - backend/app/routes/user.py | 14 +++++++++----- backend/app/schemas/user.py | 8 ++++---- .../app/services/implementations/user_service.py | 16 +++++++++++----- 4 files changed, 24 insertions(+), 15 deletions(-) diff --git a/backend/app/middleware/auth.py b/backend/app/middleware/auth.py index f9982886..22df43f6 100644 --- a/backend/app/middleware/auth.py +++ b/backend/app/middleware/auth.py @@ -6,7 +6,6 @@ from ..utilities.constants import LOGGER_NAME from ..utilities.service_utils import get_auth_service -from ..schemas.user import UserRole security = HTTPBearer() logger = logging.getLogger(LOGGER_NAME("auth")) diff --git a/backend/app/routes/user.py b/backend/app/routes/user.py index 194abef1..527c50cc 100644 --- a/backend/app/routes/user.py +++ b/backend/app/routes/user.py @@ -1,10 +1,14 @@ -from typing import List -from uuid import UUID - from fastapi import APIRouter, Depends, HTTPException from app.middleware.auth import has_roles -from app.schemas.user import UserCreateRequest, UserCreateResponse, UserListResponse, UserResponse, UserRole, UserUpdateRequest +from app.schemas.user import ( + UserCreateRequest, + UserCreateResponse, + UserListResponse, + UserResponse, + UserRole, + UserUpdateRequest, +) from app.services.implementations.user_service import UserService from app.utilities.service_utils import get_user_service @@ -92,4 +96,4 @@ async def delete_user( except HTTPException as http_ex: raise http_ex except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 91fb9468..3f7b7cbb 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -77,7 +77,7 @@ class UserUpdateRequest(BaseModel): """ Request schema for user updates, all fields optional """ - + first_name: Optional[str] = Field(None, min_length=1, max_length=50) last_name: Optional[str] = Field(None, min_length=1, max_length=50) email: Optional[EmailStr] = None @@ -106,7 +106,7 @@ class UserResponse(BaseModel): """ Response schema for user data including role information """ - + id: UUID first_name: str last_name: str @@ -123,7 +123,7 @@ class RoleResponse(BaseModel): """ Response schema for role data """ - + id: int name: str @@ -134,6 +134,6 @@ class UserListResponse(BaseModel): """ Response schema for listing users """ - + users: List[UserResponse] total: int diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index 0bb0cbcf..f8ae233b 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -97,7 +97,7 @@ def delete_user_by_email(self, email: str): self.db.rollback() self.logger.error(f"Error deleting user with email {email}: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) - + def delete_user_by_id(self, user_id: str): try: db_user = self.db.query(User).filter(User.id == UUID(user_id)).first() @@ -131,7 +131,9 @@ def get_user_by_email(self, email: str): def get_user_by_id(self, user_id: str) -> UserResponse: try: - user = self.db.query(User).join(Role).filter(User.id == UUID(user_id)).first() + user = ( + self.db.query(User).join(Role).filter(User.id == UUID(user_id)).first() + ) if not user: raise HTTPException(status_code=404, detail="User not found") return UserResponse.model_validate(user) @@ -165,7 +167,9 @@ def get_users(self) -> List[UserResponse]: self.logger.error(f"Error retrieving users: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) - def update_user_by_id(self, user_id: str, user_update: UserUpdateRequest) -> UserResponse: + def update_user_by_id( + self, user_id: str, user_update: UserUpdateRequest + ) -> UserResponse: try: db_user = self.db.query(User).filter(User.id == UUID(user_id)).first() if not db_user: @@ -173,7 +177,7 @@ def update_user_by_id(self, user_id: str, user_update: UserUpdateRequest) -> Use # update provided fields only update_data = user_update.model_dump(exclude_unset=True) - + # handle role conversion if role is being updated if "role" in update_data: update_data["role_id"] = UserRole.to_role_id(update_data.pop("role")) @@ -185,7 +189,9 @@ def update_user_by_id(self, user_id: str, user_update: UserUpdateRequest) -> Use self.db.refresh(db_user) # return user with role information - updated_user = self.db.query(User).join(Role).filter(User.id == UUID(user_id)).first() + updated_user = ( + self.db.query(User).join(Role).filter(User.id == UUID(user_id)).first() + ) return UserResponse.model_validate(updated_user) except ValueError: From 8ca76c63d435ed2f0a182e782687a332a3ab9c39 Mon Sep 17 00:00:00 2001 From: Evan Wu Date: Wed, 4 Jun 2025 16:17:25 -0400 Subject: [PATCH 3/4] fixed small syntax error --- backend/app/routes/auth.py | 3 +-- backend/app/routes/user.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index fc2557cd..aeb257c7 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,9 +1,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from ..middleware.auth import UserRole from ..schemas.auth import AuthResponse, LoginRequest, RefreshRequest, Token -from ..schemas.user import UserCreateRequest, UserCreateResponse +from ..schemas.user import UserRole, UserCreateRequest, UserCreateResponse from ..services.implementations.auth_service import AuthService from ..services.implementations.user_service import UserService from ..utilities.service_utils import get_auth_service, get_user_service diff --git a/backend/app/routes/user.py b/backend/app/routes/user.py index 527c50cc..b27ce9fd 100644 --- a/backend/app/routes/user.py +++ b/backend/app/routes/user.py @@ -73,7 +73,7 @@ async def update_user( user_id: str, user_update: UserUpdateRequest, user_service: UserService = Depends(get_user_service), - authorized: bool = Depends(has_roles([UserRole.ADMIN])), + authorized: bool = has_roles([UserRole.ADMIN]), ): try: return user_service.update_user_by_id(user_id, user_update) From fc377f08aa81a979b20cbcf2219dd34c478aa11c Mon Sep 17 00:00:00 2001 From: Evan Wu Date: Sat, 7 Jun 2025 01:09:34 -0400 Subject: [PATCH 4/4] added unit tests, migration and split admin/user/participant get --- backend/app/interfaces/user_service.py | 11 + backend/app/routes/auth.py | 2 +- backend/app/routes/user.py | 18 +- .../services/implementations/user_service.py | 28 +- .../8bfb115acac1_add_approved_to_users.py | 30 ++ backend/tests/unit/test_user.py | 432 +++++++++++++++++- 6 files changed, 504 insertions(+), 17 deletions(-) create mode 100644 backend/migrations/versions/8bfb115acac1_add_approved_to_users.py diff --git a/backend/app/interfaces/user_service.py b/backend/app/interfaces/user_service.py index c1585e1a..4f9a3329 100644 --- a/backend/app/interfaces/user_service.py +++ b/backend/app/interfaces/user_service.py @@ -137,3 +137,14 @@ def delete_user_by_email(self, email): :raises Exception: if user deletion fails """ pass + + @abstractmethod + def get_admins(self): + """ + Get all admin users + + :return: list of UserDTOs for admin users + :rtype: [UserDTO] + :raises Exception: if user retrieval fails + """ + pass diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index aeb257c7..67d0799e 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -2,7 +2,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from ..schemas.auth import AuthResponse, LoginRequest, RefreshRequest, Token -from ..schemas.user import UserRole, UserCreateRequest, UserCreateResponse +from ..schemas.user import UserCreateRequest, UserCreateResponse, UserRole from ..services.implementations.auth_service import AuthService from ..services.implementations.user_service import UserService from ..utilities.service_utils import get_auth_service, get_user_service diff --git a/backend/app/routes/user.py b/backend/app/routes/user.py index b27ce9fd..b6b90a2d 100644 --- a/backend/app/routes/user.py +++ b/backend/app/routes/user.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query from app.middleware.auth import has_roles from app.schemas.user import ( @@ -40,11 +42,17 @@ async def create_user( # admin only get all users @router.get("/", response_model=UserListResponse) async def get_users( + admin: Optional[bool] = Query( + False, description="If true, returns admin users only" + ), user_service: UserService = Depends(get_user_service), authorized: bool = has_roles([UserRole.ADMIN]), ): try: - users = user_service.get_users() + if admin: + users = await user_service.get_admins() + else: + users = await user_service.get_users() return UserListResponse(users=users, total=len(users)) except HTTPException as http_ex: raise http_ex @@ -60,7 +68,7 @@ async def get_user( authorized: bool = has_roles([UserRole.ADMIN]), ): try: - return user_service.get_user_by_id(user_id) + return await user_service.get_user_by_id(user_id) except HTTPException as http_ex: raise http_ex except Exception as e: @@ -76,7 +84,7 @@ async def update_user( authorized: bool = has_roles([UserRole.ADMIN]), ): try: - return user_service.update_user_by_id(user_id, user_update) + return await user_service.update_user_by_id(user_id, user_update) except HTTPException as http_ex: raise http_ex except Exception as e: @@ -91,7 +99,7 @@ async def delete_user( authorized: bool = has_roles([UserRole.ADMIN]), ): try: - user_service.delete_user_by_id(user_id) + await user_service.delete_user_by_id(user_id) return {"message": "User deleted successfully"} except HTTPException as http_ex: raise http_ex diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index f8ae233b..f854e7df 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -82,7 +82,7 @@ async def create_user(self, user: UserCreateRequest) -> UserCreateResponse: raise HTTPException(status_code=500, detail=str(e)) - def delete_user_by_email(self, email: str): + async def delete_user_by_email(self, email: str): try: db_user = self.db.query(User).filter(User.email == email).first() if not db_user: @@ -98,7 +98,7 @@ def delete_user_by_email(self, email: str): self.logger.error(f"Error deleting user with email {email}: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) - def delete_user_by_id(self, user_id: str): + async def delete_user_by_id(self, user_id: str): try: db_user = self.db.query(User).filter(User.id == UUID(user_id)).first() if not db_user: @@ -116,7 +116,7 @@ def delete_user_by_id(self, user_id: str): self.logger.error(f"Error deleting user {user_id}: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) - def get_user_id_by_auth_id(self, auth_id: str) -> str: + async def get_user_id_by_auth_id(self, auth_id: str) -> str: """Get user ID for a user by their Firebase auth_id""" user = self.db.query(User).filter(User.auth_id == auth_id).first() if not user: @@ -129,7 +129,7 @@ def get_user_by_email(self, email: str): raise ValueError(f"User with email {email} not found") return user - def get_user_by_id(self, user_id: str) -> UserResponse: + async def get_user_by_id(self, user_id: str) -> UserResponse: try: user = ( self.db.query(User).join(Role).filter(User.id == UUID(user_id)).first() @@ -159,15 +159,27 @@ def get_user_role_by_auth_id(self, auth_id: str) -> str: raise ValueError(f"User with auth_id {auth_id} not found") return user.role.name - def get_users(self) -> List[UserResponse]: + async def get_users(self) -> List[UserResponse]: try: - users = self.db.query(User).join(Role).all() + # Filter users to only include participants and volunteers (role_id 1 and 2) + users = ( + self.db.query(User).join(Role).filter(User.role_id.in_([1, 2])).all() + ) + return [UserResponse.model_validate(user) for user in users] + except Exception as e: + self.logger.error(f"Error getting users: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + async def get_admins(self) -> List[UserResponse]: + try: + # Get only admin users (role_id 3) + users = self.db.query(User).join(Role).filter(User.role_id == 3).all() return [UserResponse.model_validate(user) for user in users] except Exception as e: - self.logger.error(f"Error retrieving users: {str(e)}") + self.logger.error(f"Error retrieving admin users: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) - def update_user_by_id( + async def update_user_by_id( self, user_id: str, user_update: UserUpdateRequest ) -> UserResponse: try: diff --git a/backend/migrations/versions/8bfb115acac1_add_approved_to_users.py b/backend/migrations/versions/8bfb115acac1_add_approved_to_users.py new file mode 100644 index 00000000..a6fb10a0 --- /dev/null +++ b/backend/migrations/versions/8bfb115acac1_add_approved_to_users.py @@ -0,0 +1,30 @@ +"""add approved to users + +Revision ID: 8bfb115acac1 +Revises: c9bc2b4d1036 +Create Date: 2025-06-04 16:50:38.609239 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "8bfb115acac1" +down_revision: Union[str, None] = "c9bc2b4d1036" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("users", sa.Column("approved", sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "approved") + # ### end Alembic commands ### diff --git a/backend/tests/unit/test_user.py b/backend/tests/unit/test_user.py index aa082be0..21807545 100644 --- a/backend/tests/unit/test_user.py +++ b/backend/tests/unit/test_user.py @@ -1,4 +1,7 @@ +from uuid import UUID + import pytest +from fastapi import HTTPException from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -10,6 +13,7 @@ UserCreateRequest, UserCreateResponse, UserRole, + UserUpdateRequest, ) from app.services.implementations.user_service import UserService @@ -77,9 +81,14 @@ def db_session(): session.query(Role).delete() session.commit() - # Create test role - test_role = Role(id=1, name=UserRole.PARTICIPANT) - session.add(test_role) + # Create test roles + roles = [ + Role(id=1, name=UserRole.PARTICIPANT), + Role(id=2, name=UserRole.VOLUNTEER), + Role(id=3, name=UserRole.ADMIN), + ] + for role in roles: + session.add(role) session.commit() yield session @@ -168,3 +177,420 @@ async def test_create_user_with_google(mock_firebase_auth, db_session): except Exception: db_session.rollback() # Rollback on error raise + + +@pytest.mark.asyncio +async def test_delete_user_by_email(db_session): + """Test deleting a user by email""" + try: + # Arrange + user_service = UserService(db_session) + test_user = User( + first_name="Test", + last_name="User", + email="delete@example.com", + role_id=1, + auth_id="test_auth_id", + ) + db_session.add(test_user) + db_session.commit() + + # Act + await user_service.delete_user_by_email("delete@example.com") + + # Assert + deleted_user = ( + db_session.query(User).filter_by(email="delete@example.com").first() + ) + assert deleted_user is None + + except Exception: + db_session.rollback() + raise + + +@pytest.mark.asyncio +async def test_delete_user_by_id(db_session): + """Test deleting a user by ID""" + try: + # Arrange + user_service = UserService(db_session) + test_user = User( + first_name="Test", + last_name="User", + email="delete_id@example.com", + role_id=1, + auth_id="test_auth_id", + ) + db_session.add(test_user) + db_session.commit() + user_id = str(test_user.id) + + # Act + await user_service.delete_user_by_id(user_id) + + # Assert + deleted_user = db_session.query(User).filter_by(id=test_user.id).first() + assert deleted_user is None + + except Exception: + db_session.rollback() + raise + + +@pytest.mark.asyncio +async def test_get_user_id_by_auth_id(db_session): + """Test getting user ID by auth ID""" + try: + # Arrange + user_service = UserService(db_session) + test_user = User( + first_name="Test", + last_name="User", + email="auth_id@example.com", + role_id=1, + auth_id="test_auth_id", + ) + db_session.add(test_user) + db_session.commit() + + # Act + user_id = await user_service.get_user_id_by_auth_id("test_auth_id") + + # Assert + assert user_id == str(test_user.id) + + except Exception: + db_session.rollback() + raise + + +def test_get_user_by_email(db_session): + """Test getting user by email""" + try: + # Arrange + user_service = UserService(db_session) + test_user = User( + first_name="Test", + last_name="User", + email="email@example.com", + role_id=1, + auth_id="test_auth_id", + ) + db_session.add(test_user) + db_session.commit() + + # Act + user = user_service.get_user_by_email("email@example.com") + + # Assert + assert user.email == "email@example.com" + assert user.first_name == "Test" + assert user.last_name == "User" + + except Exception: + db_session.rollback() + raise + + +@pytest.mark.asyncio +async def test_get_user_by_id(db_session): + """Test getting user by ID""" + try: + # Arrange + user_service = UserService(db_session) + test_user = User( + first_name="Test", + last_name="User", + email="id@example.com", + role_id=1, # PARTICIPANT role + auth_id="test_auth_id", + ) + db_session.add(test_user) + db_session.commit() + + # Act + user = await user_service.get_user_by_id(str(test_user.id)) + + # Assert + assert user.email == "id@example.com" + assert user.first_name == "Test" + assert user.last_name == "User" + assert user.role.name == "participant" # Compare role name string + + except Exception: + db_session.rollback() + raise + + +def test_get_auth_id_by_user_id(db_session): + """Test getting auth ID by user ID""" + try: + # Arrange + user_service = UserService(db_session) + test_user = User( + first_name="Test", + last_name="User", + email="auth@example.com", + role_id=1, + auth_id="test_auth_id", + ) + db_session.add(test_user) + db_session.commit() + + # Act + auth_id = user_service.get_auth_id_by_user_id( + test_user.id # Pass UUID object directly + ) + + # Assert + assert auth_id == "test_auth_id" + + except Exception: + db_session.rollback() + raise + + +def test_get_user_role_by_auth_id(db_session): + """Test getting user role by auth ID""" + try: + # Arrange + user_service = UserService(db_session) + test_user = User( + first_name="Test", + last_name="User", + email="role@example.com", + role_id=1, # PARTICIPANT role + auth_id="test_auth_id", + ) + db_session.add(test_user) + db_session.commit() + + # Act + role = user_service.get_user_role_by_auth_id("test_auth_id") + + # Assert + assert role == UserRole.PARTICIPANT + + # Verify database state + db_user = ( + db_session.query(User) + .join(Role) + .filter(User.auth_id == "test_auth_id") + .first() + ) + assert db_user.role.name == UserRole.PARTICIPANT + + except Exception: + db_session.rollback() + raise + + +@pytest.mark.asyncio +async def test_get_users(db_session): + """Test getting all users""" + try: + # Arrange + user_service = UserService(db_session) + test_users = [ + User( + first_name=f"Test{i}", + last_name=f"User{i}", + email=f"user{i}@example.com", + # Alternate between PARTICIPANT (1) and VOLUNTEER (2) + role_id=1 if i % 2 == 0 else 2, + auth_id=f"test_auth_id_{i}", + ) + for i in range(4) # Create 4 users + ] + # Add one admin user + admin_user = User( + first_name="Admin", + last_name="User", + email="admin@example.com", + role_id=3, # ADMIN role + auth_id="admin_auth_id", + ) + test_users.append(admin_user) + + for user in test_users: + db_session.add(user) + db_session.commit() + + # Act - Get non-admin users (default behavior) + users = await user_service.get_users() + + # Assert + assert len(users) == 4 # Should only get PARTICIPANT and VOLUNTEER users + for user in users: + assert user.role.name in ["participant", "volunteer"] # Non-admin roles + assert user.role.name != "admin" # Should not include admin users + + # Verify we have both participant and volunteer users + role_names = [user.role.name for user in users] + assert "participant" in role_names + assert "volunteer" in role_names + + except Exception: + db_session.rollback() + raise + + +@pytest.mark.asyncio +async def test_get_admins(db_session): + """Test getting admin users only""" + try: + # Arrange + user_service = UserService(db_session) + # Create some non-admin users + test_users = [ + User( + first_name=f"Test{i}", + last_name=f"User{i}", + email=f"user{i}@example.com", + role_id=1 if i % 2 == 0 else 2, + auth_id=f"test_auth_id_{i}", + ) + for i in range(4) # Create 4 non-admin users + ] + # Add two admin users + admin_users = [ + User( + first_name=f"Admin{i}", + last_name=f"User{i}", + email=f"admin{i}@example.com", + role_id=3, # ADMIN role + auth_id=f"admin_auth_id_{i}", + ) + for i in range(2) # Create 2 admin users + ] + test_users.extend(admin_users) + + for user in test_users: + db_session.add(user) + db_session.commit() + + # Act + users = await user_service.get_admins() + + # Assert + assert len(users) == 2 # Should only get admin users + for user in users: + assert user.role.name == "admin" # Should only be admin role + assert user.email.startswith("admin") # Verify admin emails + + # Verify we have both admin users + admin_emails = [user.email for user in users] + assert "admin0@example.com" in admin_emails + assert "admin1@example.com" in admin_emails + + except Exception: + db_session.rollback() + raise + + +@pytest.mark.asyncio +async def test_update_user_by_id(db_session): + """Test updating user by ID""" + try: + # Arrange + user_service = UserService(db_session) + test_user = User( + first_name="Test", + last_name="User", + email="update@example.com", + role_id=1, # PARTICIPANT role + auth_id="test_auth_id", + ) + db_session.add(test_user) + db_session.commit() + + # Act + updated_user = await user_service.update_user_by_id( + str(test_user.id), + UserUpdateRequest( + first_name="Updated", + last_name="Name", + role=UserRole.ADMIN, # Update to ADMIN role + ), + ) + + # Assert + assert updated_user.first_name == "Updated" + assert updated_user.last_name == "Name" + assert updated_user.role.name == "admin" # Compare role name string + assert updated_user.email == "update@example.com" # Unchanged + + # Verify database state + db_user = db_session.query(User).filter_by(id=test_user.id).first() + assert db_user.role_id == 3 # ADMIN role ID + + except Exception: + db_session.rollback() + raise + + +# Error case tests +@pytest.mark.asyncio +async def test_delete_nonexistent_user_by_email(db_session): + """Test deleting a non-existent user by email""" + user_service = UserService(db_session) + with pytest.raises(HTTPException) as exc_info: + await user_service.delete_user_by_email("nonexistent@example.com") + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_nonexistent_user_by_id(db_session): + """Test deleting a non-existent user by ID""" + user_service = UserService(db_session) + with pytest.raises(HTTPException) as exc_info: + await user_service.delete_user_by_id("00000000-0000-0000-0000-000000000000") + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_nonexistent_user_by_id(db_session): + """Test getting a non-existent user by ID""" + user_service = UserService(db_session) + with pytest.raises(HTTPException) as exc_info: + await user_service.get_user_by_id("00000000-0000-0000-0000-000000000000") + assert exc_info.value.status_code == 404 + + +def test_get_nonexistent_user_by_email(db_session): + """Test getting a non-existent user by email""" + user_service = UserService(db_session) + with pytest.raises(ValueError) as exc_info: + user_service.get_user_by_email("nonexistent@example.com") + assert "not found" in str(exc_info.value) + + +def test_get_nonexistent_auth_id_by_user_id(db_session): + """Test getting auth ID for non-existent user""" + user_service = UserService(db_session) + with pytest.raises(ValueError) as exc_info: + user_service.get_auth_id_by_user_id( + UUID("00000000-0000-0000-0000-000000000000") # Pass UUID object + ) + assert "not found" in str(exc_info.value) + + +def test_get_nonexistent_user_role_by_auth_id(db_session): + """Test getting role for non-existent user""" + user_service = UserService(db_session) + with pytest.raises(ValueError) as exc_info: + user_service.get_user_role_by_auth_id("nonexistent_auth_id") + assert "not found" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_update_nonexistent_user(db_session): + """Test updating a non-existent user""" + user_service = UserService(db_session) + with pytest.raises(HTTPException) as exc_info: + await user_service.update_user_by_id( + "00000000-0000-0000-0000-000000000000", + UserUpdateRequest(first_name="Updated"), + ) + assert exc_info.value.status_code == 404