diff --git a/backend/app/groups/README.md b/backend/app/groups/README.md new file mode 100644 index 0000000..00a3228 --- /dev/null +++ b/backend/app/groups/README.md @@ -0,0 +1,174 @@ +# Groups Service Implementation + +This document describes the implementation of the Groups Service API endpoints as specified in the [group-service.md](../../docs/group-service.md) documentation. + +## Overview + +The Groups Service provides 10 endpoints for managing groups, members, and group operations. All endpoints require authentication via Bearer token. + +## Implemented Endpoints + +### 1. Create Group +- **POST** `/groups` +- Creates a new group with the authenticated user as admin +- Generates a unique 6-character join code +- **Request Body**: `{name: string, currency?: string, imageUrl?: string}` +- **Response**: Full group object with join code + +### 2. List User Groups +- **GET** `/groups` +- Returns all groups where the current user is a member +- **Response**: `{groups: GroupResponse[]}` + +### 3. Get Group Details +- **GET** `/groups/{group_id}` +- Returns detailed group information including members +- Only accessible to group members +- **Response**: Full group object + +### 4. Update Group Metadata +- **PATCH** `/groups/{group_id}` +- Updates group name and/or image URL +- Only accessible to group admins +- **Request Body**: `{name?: string, imageUrl?: string}` +- **Response**: Updated group object + +### 5. Delete Group +- **DELETE** `/groups/{group_id}` +- Permanently deletes a group +- Only accessible to group admins +- **Response**: `{success: boolean, message: string}` + +### 6. Join Group by Code +- **POST** `/groups/join` +- Join a group using its join code +- **Request Body**: `{joinCode: string}` +- **Response**: `{group: GroupResponse}` + +### 7. Leave Group +- **POST** `/groups/{group_id}/leave` +- Leave a group (balance check to be implemented with expense service) +- **Response**: `{success: boolean, message: string}` + +### 8. Get Group Members +- **GET** `/groups/{group_id}/members` +- Returns list of all group members with roles +- Only accessible to group members +- **Response**: Array of member objects + +### 9. Update Member Role +- **PATCH** `/groups/{group_id}/members/{member_id}` +- Change a member's role between "admin" and "member" +- Only accessible to group admins +- **Request Body**: `{role: "admin" | "member"}` +- **Response**: `{message: string}` + +### 10. Remove Member +- **DELETE** `/groups/{group_id}/members/{member_id}` +- Remove a member from the group +- Only accessible to group admins +- Cannot remove yourself (use leave group instead) +- **Response**: `{success: boolean, message: string}` + +## File Structure + +``` +backend/app/groups/ +├── __init__.py +├── schemas.py # Pydantic models for request/response +├── service.py # Business logic and database operations +└── routes.py # FastAPI route handlers +``` + +## Security and Business Logic + +### Admin Protection Logic +- **Last Admin Protection**: Prevents the last admin from demoting themselves or leaving the group +- **Self-Removal Prevention**: Admins cannot remove themselves (must use leave group) +- **Role Validation**: Only allows "admin" or "member" roles + +### Error Handling Improvements +- **Group Existence Check**: Properly distinguishes between non-existent groups and access denied +- **Member Validation**: Validates member existence before role changes or removal +- **Balance Integration Ready**: TODOs marked for expense service integration + +## Key Features + +### Security +- All endpoints require authentication +- Role-based access control (admin vs member permissions) +- Users can only access groups they belong to + +### Join Code System +- Unique 6-character alphanumeric codes +- Case-insensitive lookup +- Collision detection and retry logic + +### Error Handling +- Proper HTTP status codes +- Descriptive error messages +- Graceful handling of invalid ObjectIds + +### Database Operations +- Uses MongoDB with proper ObjectId handling +- Optimized queries with user membership filters +- Atomic operations for member management + +## Testing + +Comprehensive test suites are provided: +- `tests/groups/test_groups_routes.py` - API endpoint tests +- `tests/groups/test_groups_service.py` - Service layer tests + +Run tests with: +```bash +cd backend +pytest tests/groups/ -v +``` + +## Integration Notes + +### With Auth Service +- Uses `get_current_user` dependency for authentication +- Requires valid JWT bearer token + +### With Expense Service (Future) +- Leave group and remove member operations will check for outstanding balances +- Currently allows operations without balance verification +- TODO comments mark integration points + +## API Usage Examples + +### Create a Group +```bash +curl -X POST "http://localhost:8000/groups" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "Weekend Trip", "currency": "USD"}' +``` + +### Join a Group +```bash +curl -X POST "http://localhost:8000/groups/join" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"joinCode": "ABC123"}' +``` + +### Update Member Role +```bash +curl -X PATCH "http://localhost:8000/groups/{group_id}/members/{member_id}" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"role": "admin"}' +``` + +## Schema Definitions + +All request/response schemas are defined in `schemas.py` with proper validation: +- Field length limits +- Required vs optional fields +- Pattern validation for roles +- Alias support for MongoDB ObjectId fields + +The Groups Service is now fully implemented and ready for integration with the frontend and other services. diff --git a/backend/app/groups/__init__.py b/backend/app/groups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/groups/routes.py b/backend/app/groups/routes.py new file mode 100644 index 0000000..22e4543 --- /dev/null +++ b/backend/app/groups/routes.py @@ -0,0 +1,127 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from app.groups.schemas import ( + GroupCreateRequest, GroupResponse, GroupListResponse, GroupUpdateRequest, + JoinGroupRequest, JoinGroupResponse, MemberRoleUpdateRequest, + LeaveGroupResponse, DeleteGroupResponse, RemoveMemberResponse +) +from app.groups.service import group_service +from app.auth.security import get_current_user +from typing import Dict, Any, List + +router = APIRouter(prefix="/groups", tags=["Groups"]) + +@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED) +async def create_group( + group_data: GroupCreateRequest, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Create a new group""" + group = await group_service.create_group( + group_data.model_dump(exclude_unset=True), + current_user["_id"] + ) + if not group: + raise HTTPException(status_code=500, detail="Failed to create group") + return group + +@router.get("", response_model=GroupListResponse) +async def list_user_groups(current_user: Dict[str, Any] = Depends(get_current_user)): + """List all groups the current user belongs to""" + groups = await group_service.get_user_groups(current_user["_id"]) + return {"groups": groups} + +@router.get("/{group_id}", response_model=GroupResponse) +async def get_group_details( + group_id: str, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Get group details including members""" + group = await group_service.get_group_by_id(group_id, current_user["_id"]) + if not group: + raise HTTPException(status_code=404, detail="Group not found or access denied") + return group + +@router.patch("/{group_id}", response_model=GroupResponse) +async def update_group_metadata( + group_id: str, + updates: GroupUpdateRequest, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Update group metadata (admin only)""" + update_data = updates.model_dump(exclude_unset=True) + if not update_data: + raise HTTPException(status_code=400, detail="No update fields provided") + + updated_group = await group_service.update_group(group_id, update_data, current_user["_id"]) + if not updated_group: + raise HTTPException(status_code=404, detail="Group not found or access denied") + return updated_group + +@router.delete("/{group_id}", response_model=DeleteGroupResponse) +async def delete_group( + group_id: str, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Delete a group (admin only)""" + deleted = await group_service.delete_group(group_id, current_user["_id"]) + if not deleted: + raise HTTPException(status_code=404, detail="Group not found or access denied") + return DeleteGroupResponse(success=True, message="Group deleted successfully") + +@router.post("/join", response_model=JoinGroupResponse) +async def join_group_by_code( + join_data: JoinGroupRequest, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Join a group using a join code""" + group = await group_service.join_group_by_code(join_data.joinCode, current_user["_id"]) + if not group: + raise HTTPException(status_code=404, detail="Invalid join code") + return {"group": group} + +@router.post("/{group_id}/leave", response_model=LeaveGroupResponse) +async def leave_group( + group_id: str, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Leave a group (only if no outstanding balances)""" + left = await group_service.leave_group(group_id, current_user["_id"]) + if not left: + raise HTTPException(status_code=400, detail="Failed to leave group") + return LeaveGroupResponse(success=True, message="Successfully left the group") + +@router.get("/{group_id}/members", response_model=List[Dict[str, Any]]) +async def get_group_members( + group_id: str, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Get list of group members""" + members = await group_service.get_group_members(group_id, current_user["_id"]) + return members + +@router.patch("/{group_id}/members/{member_id}", response_model=Dict[str, str]) +async def update_member_role( + group_id: str, + member_id: str, + role_update: MemberRoleUpdateRequest, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Change member role (admin only)""" + updated = await group_service.update_member_role( + group_id, member_id, role_update.role, current_user["_id"] + ) + if not updated: + raise HTTPException(status_code=400, detail="Failed to update member role") + return {"message": f"Member role updated to {role_update.role}"} + +@router.delete("/{group_id}/members/{member_id}", response_model=RemoveMemberResponse) +async def remove_group_member( + group_id: str, + member_id: str, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """Remove a member from the group (admin only)""" + removed = await group_service.remove_member(group_id, member_id, current_user["_id"]) + if not removed: + raise HTTPException(status_code=400, detail="Failed to remove member") + return RemoveMemberResponse(success=True, message="Member removed successfully") diff --git a/backend/app/groups/schemas.py b/backend/app/groups/schemas.py new file mode 100644 index 0000000..b4b7afc --- /dev/null +++ b/backend/app/groups/schemas.py @@ -0,0 +1,53 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + +class GroupMember(BaseModel): + userId: str + role: str = "member" # "admin" or "member" + joinedAt: datetime + +class GroupCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + currency: Optional[str] = "USD" + imageUrl: Optional[str] = None + +class GroupUpdateRequest(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + imageUrl: Optional[str] = None + +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[GroupMember]] = [] + + model_config = {"populate_by_name": True} + +class GroupListResponse(BaseModel): + groups: List[GroupResponse] + +class JoinGroupRequest(BaseModel): + joinCode: str = Field(..., min_length=1) + +class JoinGroupResponse(BaseModel): + group: GroupResponse + +class MemberRoleUpdateRequest(BaseModel): + role: str = Field(..., pattern="^(admin|member)$") + +class LeaveGroupResponse(BaseModel): + success: bool + message: str + +class DeleteGroupResponse(BaseModel): + success: bool + message: str + +class RemoveMemberResponse(BaseModel): + success: bool + message: str diff --git a/backend/app/groups/service.py b/backend/app/groups/service.py new file mode 100644 index 0000000..a4bbd2f --- /dev/null +++ b/backend/app/groups/service.py @@ -0,0 +1,297 @@ +from fastapi import HTTPException, status +from app.database import get_database +from bson import ObjectId +from datetime import datetime, timezone +from typing import Optional, Dict, Any, List +import secrets +import string + +class GroupService: + def __init__(self): + pass + + def get_db(self): + return get_database() + + def generate_join_code(self, length: int = 6) -> str: + """Generate a random alphanumeric join code""" + characters = string.ascii_uppercase + string.digits + return ''.join(secrets.choice(characters) for _ in range(length)) + + def transform_group_document(self, group: dict) -> dict: + """Transform MongoDB group document to API response format""" + if not group: + return None + try: + group_id = str(group["_id"]) + except Exception: + return None + + return { + "_id": group_id, + "name": group.get("name"), + "currency": group.get("currency", "USD"), + "joinCode": group.get("joinCode"), + "createdBy": group.get("createdBy"), + "createdAt": group.get("createdAt"), + "imageUrl": group.get("imageUrl"), + "members": group.get("members", []) + } + + async def create_group(self, group_data: dict, user_id: str) -> dict: + """Create a new group with the user as admin""" + db = self.get_db() + + # Generate unique join code + join_code = None + for _ in range(10): # Try up to 10 times to generate unique code + join_code = self.generate_join_code() + existing = await db.groups.find_one({"joinCode": join_code}) + if not existing: + break + + if not join_code: + raise HTTPException(status_code=500, detail="Failed to generate unique join code") + + now = datetime.now(timezone.utc) + group_doc = { + "name": group_data["name"], + "currency": group_data.get("currency", "USD"), + "imageUrl": group_data.get("imageUrl"), + "joinCode": join_code, + "createdBy": user_id, + "createdAt": now, + "members": [{ + "userId": user_id, + "role": "admin", + "joinedAt": now + }] + } + + result = await db.groups.insert_one(group_doc) + created_group = await db.groups.find_one({"_id": result.inserted_id}) + return self.transform_group_document(created_group) + + async def get_user_groups(self, user_id: str) -> List[dict]: + """Get all groups where user is a member""" + db = self.get_db() + cursor = db.groups.find({"members.userId": user_id}) + groups = [] + async for group in cursor: + transformed = self.transform_group_document(group) + if transformed: + groups.append(transformed) + return groups + + async def get_group_by_id(self, group_id: str, user_id: str) -> Optional[dict]: + """Get group details by ID, only if user is a member""" + db = self.get_db() + try: + obj_id = ObjectId(group_id) + except Exception: + return None + + group = await db.groups.find_one({ + "_id": obj_id, + "members.userId": user_id + }) + return self.transform_group_document(group) + + async def update_group(self, group_id: str, updates: dict, user_id: str) -> Optional[dict]: + """Update group metadata (admin only)""" + db = self.get_db() + try: + obj_id = ObjectId(group_id) + except Exception: + return None + + # Check if user is admin + group = await db.groups.find_one({ + "_id": obj_id, + "members": {"$elemMatch": {"userId": user_id, "role": "admin"}} + }) + if not group: + raise HTTPException(status_code=403, detail="Only group admins can update group details") + + result = await db.groups.find_one_and_update( + {"_id": obj_id}, + {"$set": updates}, + return_document=True + ) + return self.transform_group_document(result) + + async def delete_group(self, group_id: str, user_id: str) -> bool: + """Delete group (admin only)""" + db = self.get_db() + try: + obj_id = ObjectId(group_id) + except Exception: + return False + + # Check if user is admin + group = await db.groups.find_one({ + "_id": obj_id, + "members": {"$elemMatch": {"userId": user_id, "role": "admin"}} + }) + if not group: + raise HTTPException(status_code=403, detail="Only group admins can delete groups") + + result = await db.groups.delete_one({"_id": obj_id}) + return result.deleted_count == 1 + + async def join_group_by_code(self, join_code: str, user_id: str) -> Optional[dict]: + """Join a group using join code""" + db = self.get_db() + + # Find group by join code + group = await db.groups.find_one({"joinCode": join_code.upper()}) + if not group: + raise HTTPException(status_code=404, detail="Invalid join code") + + # Check if user is already a member + existing_member = next((m for m in group.get("members", []) if m["userId"] == user_id), None) + if existing_member: + raise HTTPException(status_code=400, detail="You are already a member of this group") + + # Add user as member + new_member = { + "userId": user_id, + "role": "member", + "joinedAt": datetime.now(timezone.utc) + } + + result = await db.groups.find_one_and_update( + {"_id": group["_id"]}, + {"$push": {"members": new_member}}, + return_document=True + ) + return self.transform_group_document(result) + + async def leave_group(self, group_id: str, user_id: str) -> bool: + """Leave a group (only if user has no outstanding balances)""" + db = self.get_db() + try: + obj_id = ObjectId(group_id) + except Exception: + return False + + # Check if user is a member + group = await db.groups.find_one({ + "_id": obj_id, + "members.userId": user_id + }) + if not group: + raise HTTPException(status_code=404, detail="Group not found or you are not a member") + + # Check if user is the last admin + user_member = next((m for m in group.get("members", []) if m["userId"] == user_id), None) + if user_member and user_member["role"] == "admin": + admin_count = sum(1 for m in group.get("members", []) if m["role"] == "admin") + if admin_count <= 1: + raise HTTPException( + status_code=400, + detail="Cannot leave group when you are the only admin. Delete the group or promote another member to admin first." + ) + + # TODO: Check for outstanding balances with expense service + # For now, we'll allow leaving without balance check + # This should be implemented when expense service is ready + + result = await db.groups.update_one( + {"_id": obj_id}, + {"$pull": {"members": {"userId": user_id}}} + ) + return result.modified_count == 1 + + async def get_group_members(self, group_id: str, user_id: str) -> List[dict]: + """Get list of group members""" + db = self.get_db() + try: + obj_id = ObjectId(group_id) + except Exception: + return [] + + group = await db.groups.find_one({ + "_id": obj_id, + "members.userId": user_id + }) + if not group: + return [] + + return group.get("members", []) + + async def update_member_role(self, group_id: str, member_id: str, new_role: str, user_id: str) -> bool: + """Update member role (admin only)""" + db = self.get_db() + try: + obj_id = ObjectId(group_id) + except Exception: + return False + + # Check if user is admin + group = await db.groups.find_one({ + "_id": obj_id, + "members": {"$elemMatch": {"userId": user_id, "role": "admin"}} + }) + if not group: + raise HTTPException(status_code=403, detail="Only group admins can update member roles") + + # Check if target member exists + target_member = next((m for m in group.get("members", []) if m["userId"] == member_id), None) + if not target_member: + raise HTTPException(status_code=404, detail="Member not found in group") + + # Prevent admins from demoting themselves if they are the only admin + if member_id == user_id and new_role != "admin": + admin_count = sum(1 for m in group.get("members", []) if m["role"] == "admin") + if admin_count <= 1: + raise HTTPException( + status_code=400, + detail="Cannot demote yourself when you are the only admin. Promote another member to admin first." + ) + + result = await db.groups.update_one( + {"_id": obj_id, "members.userId": member_id}, + {"$set": {"members.$.role": new_role}} + ) + return result.modified_count == 1 + + async def remove_member(self, group_id: str, member_id: str, user_id: str) -> bool: + """Remove a member from group (admin only)""" + db = self.get_db() + try: + obj_id = ObjectId(group_id) + except Exception: + return False + + # Check if group exists and user is admin + group = await db.groups.find_one({ + "_id": obj_id, + "members": {"$elemMatch": {"userId": user_id, "role": "admin"}} + }) + if not group: + # Check if group exists at all + group_exists = await db.groups.find_one({"_id": obj_id}) + if not group_exists: + raise HTTPException(status_code=404, detail="Group not found") + else: + raise HTTPException(status_code=403, detail="Only group admins can remove members") + + # Check if target member exists and is not the requesting user + target_member = next((m for m in group.get("members", []) if m["userId"] == member_id), None) + if not target_member: + raise HTTPException(status_code=404, detail="Member not found in group") + + if member_id == user_id: + raise HTTPException(status_code=400, detail="Cannot remove yourself. Use leave group instead") + + # TODO: Check for outstanding balances with expense service + # For now, we'll allow removal without balance check + + result = await db.groups.update_one( + {"_id": obj_id}, + {"$pull": {"members": {"userId": member_id}}} + ) + return result.modified_count == 1 + +group_service = GroupService() diff --git a/backend/main.py b/backend/main.py index 89c88cf..b754b19 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,6 +5,7 @@ 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.groups.routes import router as groups_router from app.config import settings @asynccontextmanager @@ -102,6 +103,7 @@ async def health_check(): # Include routers app.include_router(auth_router) app.include_router(user_router) +app.include_router(groups_router) if __name__ == "__main__": import uvicorn diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6f8c6f3..4e95af9 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -40,7 +40,7 @@ def mock_firebase_admin(request): patches = [ patch("firebase_admin.credentials.Certificate", mock_certificate), patch("firebase_admin.initialize_app", mock_initialize_app), - patch("firebase_admin.auth", mock_firebase_auth) # Mock auth module + patch("firebase_admin.auth.verify_id_token", mock_firebase_auth.verify_id_token) # Mock specific function ] for p in patches: @@ -65,16 +65,23 @@ async def mock_db(): mock_database_instance = mock_mongo_client["test_db"] print(f"mock_db fixture: mock_database_instance type: {type(mock_database_instance)}, is None: {mock_database_instance is None}") - # Ensure we are patching the correct target - # 'app.database.get_database' is where the function is defined. - # 'app.auth.service.get_database' is where it's imported and looked up by AuthService. - # Patching where it's looked up can be more robust. + # Patch get_database for all services that use it + patches = [ + patch("app.auth.service.get_database", return_value=mock_database_instance), + patch("app.user.service.get_database", return_value=mock_database_instance), + patch("app.groups.service.get_database", return_value=mock_database_instance), + ] + + # Start all patches + for p in patches: + p.start() - with patch("app.auth.service.get_database", return_value=mock_database_instance) as mock_get_database_function: - print(f"mock_db fixture: Patching app.auth.service.get_database. Patched object: {mock_get_database_function}") - print(f"mock_db fixture: Patched return_value: {mock_get_database_function.return_value}, type: {type(mock_get_database_function.return_value)}") - yield mock_database_instance # yield the same instance for direct use if needed - print("mock_db fixture: Restoring app.auth.service.get_database") + try: + yield mock_database_instance + finally: + # Stop all patches + for p in patches: + p.stop() # Optional: clear all collections in the mock_database after each test # This ensures test isolation. diff --git a/backend/tests/groups/__init__.py b/backend/tests/groups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/groups/test_groups_routes.py b/backend/tests/groups/test_groups_routes.py new file mode 100644 index 0000000..ef61a64 --- /dev/null +++ b/backend/tests/groups/test_groups_routes.py @@ -0,0 +1,258 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from fastapi import status +from main import app +from app.auth.security import create_access_token +from datetime import datetime, timedelta +from unittest.mock import patch + +# Sample user data for testing +TEST_USER_ID = "60c72b2f9b1e8a3f9c8b4567" +TEST_USER_EMAIL = "testuser@example.com" + +@pytest.fixture +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}"} + +@pytest.fixture +async def async_client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + yield ac + + +class TestGroupsRoutes: + """Test cases for Groups API endpoints""" + + @pytest.mark.asyncio + async def test_create_group_success(self, async_client: AsyncClient, auth_headers, mock_db): + """Test successful group creation""" + group_data = { + "name": "Test Group", + "currency": "USD" + } + + with patch("app.groups.service.group_service.create_group") as mock_create: + mock_create.return_value = { + "_id": "642f1e4a9b3c2d1f6a1b2c3d", + "name": "Test Group", + "currency": "USD", + "joinCode": "ABC123", + "createdBy": "user123", + "createdAt": "2023-01-01T00:00:00Z", + "imageUrl": None, + "members": [{ + "userId": "user123", + "role": "admin", + "joinedAt": "2023-01-01T00:00:00Z" + }] + } + + response = await async_client.post("/groups", json=group_data, headers=auth_headers) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == "Test Group" + assert data["currency"] == "USD" + assert "joinCode" in data + + @pytest.mark.asyncio + async def test_create_group_empty_name(self, async_client: AsyncClient, auth_headers, mock_db): + """Test group creation with empty name""" + group_data = { + "name": "", + "currency": "USD" + } + + response = await async_client.post("/groups", json=group_data, headers=auth_headers) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + @pytest.mark.asyncio + async def test_list_user_groups(self, async_client: AsyncClient, auth_headers, mock_db): + """Test listing user groups""" + with patch("app.groups.service.group_service.get_user_groups") as mock_get: + mock_get.return_value = [{ + "_id": "642f1e4a9b3c2d1f6a1b2c3d", + "name": "Test Group", + "currency": "USD", + "joinCode": "ABC123", + "createdBy": "user123", + "createdAt": "2023-01-01T00:00:00Z", + "imageUrl": None, + "members": [] + }] + + response = await async_client.get("/groups", headers=auth_headers) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert "groups" in data + assert len(data["groups"]) == 1 + + @pytest.mark.asyncio + async def test_get_group_details(self, async_client: AsyncClient, auth_headers, mock_db): + """Test getting group details""" + group_id = "642f1e4a9b3c2d1f6a1b2c3d" + + with patch("app.groups.service.group_service.get_group_by_id") as mock_get: + mock_get.return_value = { + "_id": group_id, + "name": "Test Group", + "currency": "USD", + "joinCode": "ABC123", + "createdBy": "user123", + "createdAt": "2023-01-01T00:00:00Z", + "imageUrl": None, + "members": [] + } + + response = await async_client.get(f"/groups/{group_id}", headers=auth_headers) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["_id"] == group_id + + @pytest.mark.asyncio + async def test_get_group_not_found(self, async_client: AsyncClient, auth_headers, mock_db): + """Test getting non-existent group""" + group_id = "642f1e4a9b3c2d1f6a1b2c3d" + + with patch("app.groups.service.group_service.get_group_by_id") as mock_get: + mock_get.return_value = None + + response = await async_client.get(f"/groups/{group_id}", headers=auth_headers) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.asyncio + async def test_update_group_metadata(self, async_client: AsyncClient, auth_headers, mock_db): + """Test updating group metadata""" + group_id = "642f1e4a9b3c2d1f6a1b2c3d" + update_data = {"name": "Updated Group Name"} + + with patch("app.groups.service.group_service.update_group") as mock_update: + mock_update.return_value = { + "_id": group_id, + "name": "Updated Group Name", + "currency": "USD", + "joinCode": "ABC123", + "createdBy": "user123", + "createdAt": "2023-01-01T00:00:00Z", + "imageUrl": None, + "members": [] + } + + response = await async_client.patch(f"/groups/{group_id}", json=update_data, headers=auth_headers) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == "Updated Group Name" + + @pytest.mark.asyncio + async def test_delete_group(self, async_client: AsyncClient, auth_headers, mock_db): + """Test deleting a group""" + group_id = "642f1e4a9b3c2d1f6a1b2c3d" + + with patch("app.groups.service.group_service.delete_group") as mock_delete: + mock_delete.return_value = True + + response = await async_client.delete(f"/groups/{group_id}", headers=auth_headers) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["success"] is True + + @pytest.mark.asyncio + async def test_join_group_by_code(self, async_client: AsyncClient, auth_headers, mock_db): + """Test joining a group by code""" + join_data = {"joinCode": "ABC123"} + + with patch("app.groups.service.group_service.join_group_by_code") as mock_join: + mock_join.return_value = { + "_id": "642f1e4a9b3c2d1f6a1b2c3d", + "name": "Test Group", + "currency": "USD", + "joinCode": "ABC123", + "createdBy": "user123", + "createdAt": "2023-01-01T00:00:00Z", + "imageUrl": None, + "members": [ + {"userId": "user123", "role": "admin", "joinedAt": "2023-01-01T00:00:00Z"}, + {"userId": "user456", "role": "member", "joinedAt": "2023-01-01T00:00:00Z"} + ] + } + + response = await async_client.post("/groups/join", json=join_data, headers=auth_headers) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert "group" in data + + @pytest.mark.asyncio + async def test_leave_group(self, async_client: AsyncClient, auth_headers, mock_db): + """Test leaving a group""" + group_id = "642f1e4a9b3c2d1f6a1b2c3d" + + with patch("app.groups.service.group_service.leave_group") as mock_leave: + mock_leave.return_value = True + + response = await async_client.post(f"/groups/{group_id}/leave", headers=auth_headers) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["success"] is True + + @pytest.mark.asyncio + async def test_get_group_members(self, async_client: AsyncClient, auth_headers, mock_db): + """Test getting group members""" + group_id = "642f1e4a9b3c2d1f6a1b2c3d" + + with patch("app.groups.service.group_service.get_group_members") as mock_get_members: + mock_get_members.return_value = [ + {"userId": "user123", "role": "admin", "joinedAt": "2023-01-01T00:00:00Z"}, + {"userId": "user456", "role": "member", "joinedAt": "2023-01-01T00:00:00Z"} + ] + + response = await async_client.get(f"/groups/{group_id}/members", headers=auth_headers) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 2 + + @pytest.mark.asyncio + async def test_update_member_role(self, async_client: AsyncClient, auth_headers, mock_db): + """Test updating member role""" + group_id = "642f1e4a9b3c2d1f6a1b2c3d" + member_id = "user456" + role_data = {"role": "admin"} + + with patch("app.groups.service.group_service.update_member_role") as mock_update_role: + mock_update_role.return_value = True + + response = await async_client.patch( + f"/groups/{group_id}/members/{member_id}", + json=role_data, + headers=auth_headers + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert "message" in data + + @pytest.mark.asyncio + async def test_remove_member(self, async_client: AsyncClient, auth_headers, mock_db): + """Test removing a member from group""" + group_id = "642f1e4a9b3c2d1f6a1b2c3d" + member_id = "user456" + + with patch("app.groups.service.group_service.remove_member") as mock_remove: + mock_remove.return_value = True + + response = await async_client.delete(f"/groups/{group_id}/members/{member_id}", headers=auth_headers) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["success"] is True diff --git a/backend/tests/groups/test_groups_service.py b/backend/tests/groups/test_groups_service.py new file mode 100644 index 0000000..4d3d117 --- /dev/null +++ b/backend/tests/groups/test_groups_service.py @@ -0,0 +1,367 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi import HTTPException +from bson import ObjectId +from app.groups.service import GroupService + + +class TestGroupService: + """Test cases for GroupService""" + + def setup_method(self): + """Setup for each test method""" + self.service = GroupService() + + def test_generate_join_code(self): + """Test join code generation""" + code = self.service.generate_join_code() + assert len(code) == 6 + assert code.isalnum() + assert code.isupper() + + def test_transform_group_document(self): + """Test group document transformation""" + group_doc = { + "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"), + "name": "Test Group", + "currency": "USD", + "joinCode": "ABC123", + "createdBy": "user123", + "createdAt": "2023-01-01T00:00:00Z", + "imageUrl": None, + "members": [] + } + + result = self.service.transform_group_document(group_doc) + + assert result["_id"] == "642f1e4a9b3c2d1f6a1b2c3d" + assert result["name"] == "Test Group" + assert result["currency"] == "USD" + assert result["joinCode"] == "ABC123" + + def test_transform_group_document_none(self): + """Test transform with None input""" + result = self.service.transform_group_document(None) + assert result is None + + @pytest.mark.asyncio + async def test_create_group_success(self): + """Test successful group creation""" + mock_db = AsyncMock() + mock_collection = AsyncMock() + mock_db.groups = mock_collection + + # Mock find_one to return None (no existing join code) + mock_collection.find_one.return_value = None + + # Mock insert_one + mock_result = MagicMock() + mock_result.inserted_id = ObjectId("642f1e4a9b3c2d1f6a1b2c3d") + mock_collection.insert_one.return_value = mock_result + + # Mock find_one for created group + created_group = { + "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"), + "name": "Test Group", + "currency": "USD", + "joinCode": "ABC123", + "createdBy": "user123", + "createdAt": "2023-01-01T00:00:00Z", + "imageUrl": None, + "members": [{ + "userId": "user123", + "role": "admin", + "joinedAt": "2023-01-01T00:00:00Z" + }] + } + mock_collection.find_one.side_effect = [None, created_group] + + with patch.object(self.service, 'get_db', return_value=mock_db): + result = await self.service.create_group( + {"name": "Test Group", "currency": "USD"}, + "user123" + ) + + assert result["name"] == "Test Group" + assert result["currency"] == "USD" + assert "joinCode" in result + + @pytest.mark.asyncio + async def test_get_user_groups(self): + """Test getting user groups""" + mock_db = MagicMock() # Use MagicMock instead of AsyncMock + mock_collection = MagicMock() # Use MagicMock instead of AsyncMock + mock_db.groups = mock_collection + + # Mock groups data + mock_groups = [{ + "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"), + "name": "Test Group", + "currency": "USD", + "joinCode": "ABC123", + "createdBy": "user123", + "createdAt": "2023-01-01T00:00:00Z", + "imageUrl": None, + "members": [] + }] + + # Create a proper async iterator mock + async def mock_async_iter(): + for group in mock_groups: + yield group + + # Mock cursor with proper __aiter__ method + mock_cursor = MagicMock() + mock_cursor.__aiter__ = lambda self: mock_async_iter() + mock_collection.find.return_value = mock_cursor + + with patch.object(self.service, 'get_db', return_value=mock_db): + result = await self.service.get_user_groups("user123") + + assert len(result) == 1 + assert result[0]["name"] == "Test Group" + + @pytest.mark.asyncio + async def test_join_group_by_code_success(self): + """Test successful group joining""" + mock_db = AsyncMock() + mock_collection = AsyncMock() + mock_db.groups = mock_collection + + existing_group = { + "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"), + "name": "Test Group", + "joinCode": "ABC123", + "members": [{ + "userId": "user123", + "role": "admin", + "joinedAt": "2023-01-01T00:00:00Z" + }] + } + + updated_group = { + "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"), + "name": "Test Group", + "joinCode": "ABC123", + "members": [ + {"userId": "user123", "role": "admin", "joinedAt": "2023-01-01T00:00:00Z"}, + {"userId": "user456", "role": "member", "joinedAt": "2023-01-01T00:00:00Z"} + ] + } + + mock_collection.find_one.return_value = existing_group + mock_collection.find_one_and_update.return_value = updated_group + + with patch.object(self.service, 'get_db', return_value=mock_db): + result = await self.service.join_group_by_code("ABC123", "user456") + + assert result is not None + assert len(result["members"]) == 2 + + @pytest.mark.asyncio + async def test_join_group_invalid_code(self): + """Test joining group with invalid code""" + mock_db = AsyncMock() + mock_collection = AsyncMock() + mock_db.groups = mock_collection + + mock_collection.find_one.return_value = None + + with patch.object(self.service, 'get_db', return_value=mock_db): + with pytest.raises(HTTPException) as exc_info: + await self.service.join_group_by_code("INVALID", "user456") + + assert exc_info.value.status_code == 404 + assert "Invalid join code" in str(exc_info.value.detail) + + @pytest.mark.asyncio + async def test_join_group_already_member(self): + """Test joining group when already a member""" + mock_db = AsyncMock() + mock_collection = AsyncMock() + mock_db.groups = mock_collection + + existing_group = { + "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"), + "name": "Test Group", + "joinCode": "ABC123", + "members": [{ + "userId": "user456", + "role": "member", + "joinedAt": "2023-01-01T00:00:00Z" + }] + } + + mock_collection.find_one.return_value = existing_group + + with patch.object(self.service, 'get_db', return_value=mock_db): + with pytest.raises(HTTPException) as exc_info: + await self.service.join_group_by_code("ABC123", "user456") + + assert exc_info.value.status_code == 400 + assert "already a member" in str(exc_info.value.detail) + + @pytest.mark.asyncio + async def test_update_group_not_admin(self): + """Test updating group when not admin""" + mock_db = AsyncMock() + mock_collection = AsyncMock() + mock_db.groups = mock_collection + + mock_collection.find_one.return_value = None # User not admin + + with patch.object(self.service, 'get_db', return_value=mock_db): + with pytest.raises(HTTPException) as exc_info: + await self.service.update_group("642f1e4a9b3c2d1f6a1b2c3d", {"name": "New Name"}, "user456") + + assert exc_info.value.status_code == 403 + assert "Only group admins" in str(exc_info.value.detail) + + @pytest.mark.asyncio + async def test_update_member_role_prevent_last_admin_demotion(self): + """Test preventing the last admin from demoting themselves""" + mock_db = AsyncMock() + mock_collection = AsyncMock() + mock_db.groups = mock_collection + + # Mock group with only one admin + group_with_one_admin = { + "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"), + "name": "Test Group", + "members": [ + {"userId": "user123", "role": "admin", "joinedAt": "2023-01-01T00:00:00Z"}, + {"userId": "user456", "role": "member", "joinedAt": "2023-01-01T00:00:00Z"} + ] + } + + mock_collection.find_one.return_value = group_with_one_admin + + with patch.object(self.service, 'get_db', return_value=mock_db): + with pytest.raises(HTTPException) as exc_info: + await self.service.update_member_role("642f1e4a9b3c2d1f6a1b2c3d", "user123", "member", "user123") + + assert exc_info.value.status_code == 400 + assert "Cannot demote yourself when you are the only admin" in str(exc_info.value.detail) + + @pytest.mark.asyncio + async def test_update_member_role_allow_admin_demotion_with_other_admins(self): + """Test allowing admin demotion when there are other admins""" + mock_db = AsyncMock() + mock_collection = AsyncMock() + mock_db.groups = mock_collection + + # Mock group with multiple admins + group_with_multiple_admins = { + "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"), + "name": "Test Group", + "members": [ + {"userId": "user123", "role": "admin", "joinedAt": "2023-01-01T00:00:00Z"}, + {"userId": "user456", "role": "admin", "joinedAt": "2023-01-01T00:00:00Z"}, + {"userId": "user789", "role": "member", "joinedAt": "2023-01-01T00:00:00Z"} + ] + } + + mock_collection.find_one.return_value = group_with_multiple_admins + mock_result = MagicMock() + mock_result.modified_count = 1 + mock_collection.update_one.return_value = mock_result + + with patch.object(self.service, 'get_db', return_value=mock_db): + result = await self.service.update_member_role("642f1e4a9b3c2d1f6a1b2c3d", "user123", "member", "user123") + + assert result is True + + @pytest.mark.asyncio + async def test_remove_member_group_not_found(self): + """Test removing member from non-existent group""" + mock_db = AsyncMock() + mock_collection = AsyncMock() + mock_db.groups = mock_collection + + # Mock no group found for admin check and no group exists at all + mock_collection.find_one.side_effect = [None, None] + + with patch.object(self.service, 'get_db', return_value=mock_db): + with pytest.raises(HTTPException) as exc_info: + await self.service.remove_member("642f1e4a9b3c2d1f6a1b2c3d", "user456", "user123") + + assert exc_info.value.status_code == 404 + assert "Group not found" in str(exc_info.value.detail) + + @pytest.mark.asyncio + async def test_remove_member_user_not_admin_but_group_exists(self): + """Test removing member when user is not admin but group exists""" + mock_db = AsyncMock() + mock_collection = AsyncMock() + mock_db.groups = mock_collection + + existing_group = { + "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"), + "name": "Test Group", + "members": [ + {"userId": "user123", "role": "admin", "joinedAt": "2023-01-01T00:00:00Z"}, + {"userId": "user456", "role": "member", "joinedAt": "2023-01-01T00:00:00Z"} + ] + } + + # First call returns None (user not admin), second call returns the group (group exists) + mock_collection.find_one.side_effect = [None, existing_group] + + with patch.object(self.service, 'get_db', return_value=mock_db): + with pytest.raises(HTTPException) as exc_info: + await self.service.remove_member("642f1e4a9b3c2d1f6a1b2c3d", "user456", "user789") # user789 is not admin + + assert exc_info.value.status_code == 403 + assert "Only group admins can remove members" in str(exc_info.value.detail) + + @pytest.mark.asyncio + async def test_leave_group_prevent_last_admin(self): + """Test preventing the last admin from leaving the group""" + mock_db = AsyncMock() + mock_collection = AsyncMock() + mock_db.groups = mock_collection + + # Mock group with only one admin + group_with_one_admin = { + "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"), + "name": "Test Group", + "members": [ + {"userId": "user123", "role": "admin", "joinedAt": "2023-01-01T00:00:00Z"}, + {"userId": "user456", "role": "member", "joinedAt": "2023-01-01T00:00:00Z"} + ] + } + + mock_collection.find_one.return_value = group_with_one_admin + + with patch.object(self.service, 'get_db', return_value=mock_db): + with pytest.raises(HTTPException) as exc_info: + await self.service.leave_group("642f1e4a9b3c2d1f6a1b2c3d", "user123") + + assert exc_info.value.status_code == 400 + assert "Cannot leave group when you are the only admin" in str(exc_info.value.detail) + + @pytest.mark.asyncio + async def test_leave_group_allow_member_to_leave(self): + """Test allowing regular members to leave""" + mock_db = AsyncMock() + mock_collection = AsyncMock() + mock_db.groups = mock_collection + + group = { + "_id": ObjectId("642f1e4a9b3c2d1f6a1b2c3d"), + "name": "Test Group", + "members": [ + {"userId": "user123", "role": "admin", "joinedAt": "2023-01-01T00:00:00Z"}, + {"userId": "user456", "role": "member", "joinedAt": "2023-01-01T00:00:00Z"} + ] + } + + mock_collection.find_one.return_value = group + mock_result = MagicMock() + mock_result.modified_count = 1 + mock_collection.update_one.return_value = mock_result + + with patch.object(self.service, 'get_db', return_value=mock_db): + result = await self.service.leave_group("642f1e4a9b3c2d1f6a1b2c3d", "user456") + + assert result is True