diff --git a/Readme.md b/Readme.md index 3cb446d..dbd8095 100644 --- a/Readme.md +++ b/Readme.md @@ -13,7 +13,7 @@ architecture with Redis for task queuing and MongoDB for data persistence. - **Backend API**: FastAPI (Python 3.12) - **Task Processing**: Celery with Redis broker - **Document Processing**: EasyOCR, PyMuPDF, python-docx, pdfplumber -- **Database**: MongoDB +- **Database**: MongoDB (pymongo) - **Frontend**: React - **Containerization**: Docker & Docker Compose - **File Monitoring**: Python watchdog library @@ -109,16 +109,18 @@ MyDocManager/ │ │ │ │ └── types.py # PyObjectId and other useful types │ │ │ ├── database/ │ │ │ │ ├── __init__.py -│ │ │ │ ├── connection.py # MongoDB connection +│ │ │ │ ├── connection.py # MongoDB connection (pymongo) │ │ │ │ └── repositories/ │ │ │ │ ├── __init__.py -│ │ │ │ ├── user_repository.py # User CRUD operations -│ │ │ │ └── document_repository.py # User CRUD operations +│ │ │ │ ├── user_repository.py # User CRUD operations (synchronous) +│ │ │ │ ├── document_repository.py # Document CRUD operations (synchronous) +│ │ │ │ └── job_repository.py # Job CRUD operations (synchronous) │ │ │ ├── services/ │ │ │ │ ├── __init__.py -│ │ │ │ ├── auth_service.py # JWT & password logic -│ │ │ │ ├── user_service.py # User business logic -│ │ │ │ ├── document_service.py # Document business logic +│ │ │ │ ├── auth_service.py # JWT & password logic (synchronous) +│ │ │ │ ├── user_service.py # User business logic (synchronous) +│ │ │ │ ├── document_service.py # Document business logic (synchronous) +│ │ │ │ ├── job_service.py # Job processing logic (synchronous) │ │ │ │ └── init_service.py # Admin creation at startup │ │ │ ├── api/ │ │ │ │ ├── __init__.py @@ -334,13 +336,20 @@ class ProcessingJob(BaseModel): - **Rationale**: MongoDB is not meant for large files, better performance. Files remain in the file system for easy access. -### Implementation Order +#### Repository and Services Implementation + +- **Choice**: Synchronous implementation using pymongo +- **Rationale**: Full compatibility with Celery workers and simplified workflow +- **Implementation**: All repositories and services operate synchronously for seamless integration + +### Implementation Status 1. ✅ Pydantic models for MongoDB collections -2. UNDER PROGRESS : Repository layer for data access (files + processing_jobs) -3. TODO : Celery tasks for document processing -4. TODO : Watchdog file monitoring implementation -5. TODO : FastAPI integration and startup coordination +2. ✅ Repository layer for data access (files + processing_jobs + users + documents) - synchronous +3. ✅ Service layer for business logic (auth, user, document, job) - synchronous +4. ✅ Celery tasks for document processing +5. ✅ Watchdog file monitoring implementation +6. ✅ FastAPI integration and startup coordination ## Job Management Layer @@ -350,7 +359,7 @@ The job management system follows the repository pattern for clean separation be #### JobRepository -Handles direct MongoDB operations for processing jobs: +Handles direct MongoDB operations for processing jobs using synchronous pymongo: **CRUD Operations:** - `create_job()` - Create new processing job with automatic `created_at` timestamp @@ -367,7 +376,7 @@ Handles direct MongoDB operations for processing jobs: #### JobService -Provides business logic layer with strict status transition validation: +Provides synchronous business logic layer with strict status transition validation: **Status Transition Methods:** - `mark_job_as_started()` - PENDING → PROCESSING @@ -381,7 +390,6 @@ Provides business logic layer with strict status transition validation: #### Custom Exceptions -**JobNotFoundError**: Raised when job ID doesn't exist **InvalidStatusTransitionError**: Raised for invalid status transitions **JobRepositoryError**: Raised for MongoDB operation failures @@ -400,11 +408,17 @@ All other transitions are forbidden and will raise `InvalidStatusTransitionError ``` src/file-processor/app/ ├── database/repositories/ -│ └── job_repository.py # JobRepository class +│ ├── job_repository.py # JobRepository class (synchronous) +│ ├── user_repository.py # UserRepository class (synchronous) +│ ├── document_repository.py # DocumentRepository class (synchronous) +│ └── file_repository.py # FileRepository class (synchronous) ├── services/ -│ └── job_service.py # JobService class +│ ├── job_service.py # JobService class (synchronous) +│ ├── auth_service.py # AuthService class (synchronous) +│ ├── user_service.py # UserService class (synchronous) +│ └── document_service.py # DocumentService class (synchronous) └── exceptions/ - └── job_exceptions.py # Custom exceptions + └── job_exceptions.py # Custom exceptions ``` ### Processing Pipeline Features @@ -414,6 +428,7 @@ src/file-processor/app/ - **Status Tracking**: Real-time processing status via `processing_jobs` collection - **Extensible Metadata**: Flexible metadata storage per file type - **Multiple Extraction Methods**: Support for direct text, OCR, and hybrid approaches +- **Synchronous Operations**: All database operations use pymongo for Celery compatibility ## Key Implementation Notes @@ -436,6 +451,7 @@ src/file-processor/app/ - **Package Manager**: pip (standard) - **External Dependencies**: Listed in each service's requirements.txt - **Standard Library First**: Prefer standard library when possible +- **Database Driver**: pymongo for synchronous MongoDB operations ### Testing Strategy @@ -460,6 +476,7 @@ src/file-processor/app/ 12. **Content in Files Collection**: Extracted content stored with file metadata 13. **Direct Task Dispatch**: File watcher directly creates Celery tasks 14. **SHA256 Duplicate Detection**: Prevents reprocessing identical files +15. **Synchronous Implementation**: All repositories and services use pymongo for Celery compatibility ### Development Process Requirements @@ -470,12 +487,13 @@ src/file-processor/app/ ### Next Implementation Steps -1. **IN PROGRESS**: Implement file processing pipeline => - 1. Create Pydantic models for files and processing_jobs collections - 2. Implement repository layer for file and processing job data access - 3. Create Celery tasks for document processing (.txt, .pdf, .docx) - 4. Implement Watchdog file monitoring with dedicated observer - 5. Integrate file watcher with FastAPI startup +1. **TODO**: Complete file processing pipeline => + 1. ✅ Create Pydantic models for files and processing_jobs collections + 2. ✅ Implement repository layer for file and processing job data access (synchronous) + 3. ✅ Implement service layer for business logic (synchronous) + 4. ✅ Create Celery tasks for document processing (.txt, .pdf, .docx) + 5. ✅ Implement Watchdog file monitoring with dedicated observer + 6. ✅ Integrate file watcher with FastAPI startup 2. Create protected API routes for user management 3. Build React monitoring interface with authentication @@ -566,4 +584,4 @@ docker-compose up --scale worker=3 - **file-processor**: Hot-reload enabled via `--reload` flag - Code changes in `src/file-processor/app/` automatically restart FastAPI - **worker**: No hot-reload (manual restart required for stability) - - Code changes in `src/worker/tasks/` require: `docker-compose restart worker` + - Code changes in `src/worker/tasks/` require: `docker-compose restart worker` \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f51048d..9b882a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,16 +5,22 @@ asgiref==3.9.1 bcrypt==4.3.0 billiard==4.2.1 celery==5.5.3 +certifi==2025.8.3 +cffi==2.0.0 click==8.2.1 click-didyoumean==0.3.1 click-plugins==1.1.1.2 click-repl==0.3.0 +cryptography==46.0.1 dnspython==2.8.0 +ecdsa==0.19.1 email-validator==2.3.0 fastapi==0.116.1 h11==0.16.0 hiredis==3.2.1 +httpcore==1.0.9 httptools==0.6.4 +httpx==0.28.1 idna==3.10 importlib_metadata==8.7.0 iniconfig==2.1.0 @@ -27,6 +33,8 @@ packaging==25.0 pipdeptree==2.28.0 pluggy==1.6.0 prompt_toolkit==3.0.52 +pyasn1==0.6.1 +pycparser==2.23 pycron==3.2.0 pydantic==2.11.9 pydantic_core==2.33.2 @@ -42,6 +50,7 @@ python-magic==0.4.27 pytz==2025.2 PyYAML==6.0.2 redis==6.4.0 +rsa==4.9.1 sentinels==1.1.1 six==1.17.0 sniffio==1.3.1 diff --git a/src/file-processor/app/api/__init__.py b/src/file-processor/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/file-processor/app/api/dependencies.py b/src/file-processor/app/api/dependencies.py new file mode 100644 index 0000000..cf930b9 --- /dev/null +++ b/src/file-processor/app/api/dependencies.py @@ -0,0 +1,100 @@ +import jwt +from fastapi import Depends, HTTPException +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jwt import InvalidTokenError +from starlette import status + +from app.config import settings +from app.database.connection import get_database +from app.models.auth import UserRole +from app.models.user import UserInDB +from app.services.auth_service import AuthService +from app.services.user_service import UserService + +security = HTTPBearer() + + +def get_auth_service() -> AuthService: + """Dependency to get AuthService instance.""" + return AuthService() + + +def get_user_service() -> UserService: + """Dependency to get UserService instance.""" + database = get_database() + return UserService(database) + + +def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + user_service: UserService = Depends(get_user_service) +) -> UserInDB: + """ + Dependency to get current authenticated user from JWT token. + + Args: + credentials: HTTP Bearer credentials + user_service: Auth service instance + + Returns: + User: Current authenticated user + + Raises: + HTTPException: If token is invalid or user not found + """ + try: + payload = jwt.decode( + credentials.credentials, + settings.get_jwt_secret_key(), + algorithms=[settings.get_jwt_algorithm()] + ) + username: str = payload.get("sub") + if username is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + except InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = user_service.get_user_by_username(username) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user" + ) + + return user + + +def get_admin_user(current_user: UserInDB = Depends(get_current_user)) -> UserInDB: + """ + Dependency to ensure current user has admin role. + + Args: + current_user: Current authenticated user + + Returns: + User: Current user if admin + + Raises: + HTTPException: If user is not admin + """ + if current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return current_user diff --git a/src/file-processor/app/api/routes/__init__.py b/src/file-processor/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/file-processor/app/api/routes/auth.py b/src/file-processor/app/api/routes/auth.py new file mode 100644 index 0000000..a15b8a4 --- /dev/null +++ b/src/file-processor/app/api/routes/auth.py @@ -0,0 +1,80 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm + +from app.api.dependencies import get_auth_service, get_current_user, get_user_service +from app.models.auth import LoginResponse, UserResponse +from app.models.user import UserInDB +from app.services.auth_service import AuthService +from app.services.user_service import UserService + +router = APIRouter(tags=["authentication"]) + + +@router.post("/login", response_model=LoginResponse) +def login( + form_data: OAuth2PasswordRequestForm = Depends(), + auth_service: AuthService = Depends(get_auth_service), + user_service: UserService = Depends(get_user_service) +): + """ + Authenticate user and return JWT token. + + Args: + form_data: OAuth2 password form data + auth_service: Auth service instance + user_service: User service instance + + Returns: + LoginResponse: JWT token and user info + + Raises: + HTTPException: If authentication fails + """ + incorrect_username_or_pwd = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = user_service.get_user_by_username(form_data.username) + if (not user or + not user.is_active or + not auth_service.verify_user_password(form_data.password, user.hashed_password)): + raise incorrect_username_or_pwd + + access_token = auth_service.create_access_token(data={"sub": user.username}) + + return LoginResponse( + access_token=access_token, + user=UserResponse( + _id=user.id, + username=user.username, + email=user.email, + role=user.role, + is_active=user.is_active, + created_at=user.created_at, + updated_at=user.updated_at + ) + ) + + +@router.get("/me", response_model=UserResponse) +def get_current_user_profile(current_user: UserInDB = Depends(get_current_user)): + """ + Get current user profile. + + Args: + current_user: Current authenticated user + + Returns: + UserResponse: Current user profile without sensitive data + """ + return UserResponse( + _id=current_user.id, + username=current_user.username, + email=current_user.email, + role=current_user.role, + is_active=current_user.is_active, + created_at=current_user.created_at, + updated_at=current_user.updated_at + ) diff --git a/src/file-processor/app/api/routes/users.py b/src/file-processor/app/api/routes/users.py new file mode 100644 index 0000000..9d4a01a --- /dev/null +++ b/src/file-processor/app/api/routes/users.py @@ -0,0 +1,172 @@ +from fastapi import APIRouter, Depends, HTTPException +from starlette import status + +from app.api.dependencies import get_admin_user, get_user_service +from app.models.auth import UserResponse, MessageResponse +from app.models.types import PyObjectId +from app.models.user import UserInDB, UserCreate, UserUpdate +from app.services.user_service import UserService + +router = APIRouter(tags=["users"]) + + +@router.get("", response_model=list[UserInDB]) +def list_users( + admin_user: UserInDB = Depends(get_admin_user), + user_service: UserService = Depends(get_user_service) +): + """ + List all users (admin only). + + Args: + admin_user: Current admin user + user_service: User service instance + + Returns: + List[UserResponse]: List of all users without sensitive data + """ + return user_service.list_users() + + +@router.get("/{user_id}", response_model=UserResponse) +def get_user_by_id( + user_id: PyObjectId, + admin_user: UserInDB = Depends(get_admin_user), + user_service: UserService = Depends(get_user_service) +): + """ + Get specific user by ID (admin only). + + Args: + user_id: User ID to retrieve + admin_user: Current admin user + user_service: User service instance + + Returns: + UserResponse: User information without sensitive data + + Raises: + HTTPException: If user not found + """ + user = user_service.get_user_by_id(str(user_id)) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + return user + + +@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +def create_user( + user_data: UserCreate, + admin_user: UserInDB = Depends(get_admin_user), + user_service: UserService = Depends(get_user_service) +): + """ + Create new user (admin only). + + Args: + user_data: User creation data + admin_user: Current admin user + user_service: User service instance + + Returns: + UserResponse: Created user information without sensitive data + + Raises: + HTTPException: If user creation fails + """ + try: + user = user_service.create_user(user_data) + return UserResponse( + _id=user.id, + username=user.username, + email=user.email, + role=user.role, + is_active=user.is_active, + created_at=user.created_at, + updated_at=user.updated_at + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +@router.put("/{user_id}", response_model=UserResponse) +def update_user( + user_id: PyObjectId, + user_data: UserUpdate, + admin_user: UserInDB = Depends(get_admin_user), + user_service: UserService = Depends(get_user_service) +): + """ + Update existing user (admin only). + + Args: + user_id: User ID to update + user_data: User update data + admin_user: Current admin user + user_service: User service instance + + Returns: + UserResponse: Updated user information without sensitive data + + Raises: + HTTPException: If user not found or update fails + """ + try: + user = user_service.update_user(str(user_id), user_data) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + return UserResponse( + _id=user.id, + username=user.username, + email=user.email, + role=user.role, + is_active=user.is_active, + created_at=user.created_at, + updated_at=user.updated_at + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +@router.delete("/{user_id}", response_model=MessageResponse) +def delete_user( + user_id: PyObjectId, + admin_user: UserInDB = Depends(get_admin_user), + user_service: UserService = Depends(get_user_service) +): + """ + Delete user by ID (admin only). + + Args: + user_id: User ID to delete + admin_user: Current admin user + user_service: User service instance + + Returns: + MessageResponse: Success message + + Raises: + HTTPException: If user not found or deletion fails + """ + success = user_service.delete_user(str(user_id)) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + return MessageResponse(message="User successfully deleted") diff --git a/src/file-processor/app/main.py b/src/file-processor/app/main.py index 69baf1a..9fb75fb 100644 --- a/src/file-processor/app/main.py +++ b/src/file-processor/app/main.py @@ -15,6 +15,8 @@ from typing import AsyncGenerator from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.api.routes.auth import router as auth_router +from app.api.routes.users import router as users_router from app.config import settings from app.database.connection import get_database from app.file_watcher import create_file_watcher, FileWatcher @@ -23,12 +25,6 @@ from app.services.init_service import InitializationService from app.services.job_service import JobService from app.services.user_service import UserService -# from api.routes.auth import router as auth_router -# from api.routes.users import router as users_router -# from api.routes.documents import router as documents_router -# from api.routes.jobs import router as jobs_router - - # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -112,10 +108,9 @@ app.add_middleware( allow_headers=["*"], ) - # Include routers -# app.include_router(auth_router, prefix="/auth", tags=["Authentication"]) -# app.include_router(users_router, prefix="/users", tags=["User Management"]) +app.include_router(auth_router, prefix="/auth", tags=["Authentication"]) +app.include_router(users_router, prefix="/users", tags=["User Management"]) # app.include_router(documents_router, prefix="/documents", tags=["Documents"]) # app.include_router(jobs_router, prefix="/jobs", tags=["Processing Jobs"]) diff --git a/src/file-processor/app/models/auth.py b/src/file-processor/app/models/auth.py index e40644a..5d3b83a 100644 --- a/src/file-processor/app/models/auth.py +++ b/src/file-processor/app/models/auth.py @@ -3,12 +3,45 @@ Authentication models and enums for user management. Contains user roles enumeration and authentication-related Pydantic models. """ - +from datetime import datetime from enum import Enum +from pydantic import BaseModel, Field + +from app.models.types import PyObjectId + class UserRole(str, Enum): """User roles enumeration with string values.""" USER = "user" - ADMIN = "admin" \ No newline at end of file + ADMIN = "admin" + + +class UserResponse(BaseModel): + """Model for user data in API responses (excludes password_hash).""" + + id: PyObjectId = Field(alias="_id") + username: str + email: str + role: UserRole + is_active: bool + created_at: datetime + updated_at: datetime + + model_config = { + "populate_by_name": True, + "arbitrary_types_allowed": True, + } + + +class LoginResponse(BaseModel): + """Response model for successful login.""" + access_token: str + token_type: str = "bearer" + user: UserResponse + + +class MessageResponse(BaseModel): + """Generic message response.""" + message: str diff --git a/src/file-processor/app/models/user.py b/src/file-processor/app/models/user.py index 5759b04..4b54e87 100644 --- a/src/file-processor/app/models/user.py +++ b/src/file-processor/app/models/user.py @@ -7,10 +7,10 @@ and API responses with proper validation and type safety. import re from datetime import datetime -from typing import Optional, Any +from typing import Optional + from bson import ObjectId from pydantic import BaseModel, Field, field_validator, EmailStr -from pydantic_core import core_schema from app.models.auth import UserRole from app.models.types import PyObjectId @@ -138,21 +138,3 @@ class UserInDB(BaseModel): "arbitrary_types_allowed": True, "json_encoders": {ObjectId: str} } - - -class UserResponse(BaseModel): - """Model for user data in API responses (excludes password_hash).""" - - id: PyObjectId = Field(alias="_id") - username: str - email: str - role: UserRole - is_active: bool - created_at: datetime - updated_at: datetime - - model_config = { - "populate_by_name": True, - "arbitrary_types_allowed": True, - "json_encoders": {ObjectId: str} - } diff --git a/src/file-processor/app/services/auth_service.py b/src/file-processor/app/services/auth_service.py index a7037d3..53cf040 100644 --- a/src/file-processor/app/services/auth_service.py +++ b/src/file-processor/app/services/auth_service.py @@ -4,7 +4,11 @@ Authentication service for password hashing and verification. This module provides authentication-related functionality including password hashing, verification, and JWT token management. """ +from datetime import datetime, timedelta +import jwt + +from app.config import settings from app.utils.security import hash_password, verify_password @@ -55,4 +59,26 @@ class AuthService: >>> auth.verify_user_password("wrongpassword", hashed) False """ - return verify_password(password, hashed_password) \ No newline at end of file + return verify_password(password, hashed_password) + + @staticmethod + def create_access_token(data=dict) -> str: + """ + Create a JWT access token. + + Args: + data (dict): Payload data to include in the token. + + Returns: + str: Encoded JWT token. + """ + # Copy data to avoid modifying the original dict + to_encode = data.copy() + + # Add expiration time + expire = datetime.now() + timedelta(hours=settings.get_jwt_expire_hours()) + to_encode.update({"exp": expire}) + + # Encode JWT + encoded_jwt = jwt.encode(to_encode, settings.get_jwt_secret_key(), algorithm=settings.get_jwt_algorithm()) + return encoded_jwt diff --git a/src/file-processor/requirements.txt b/src/file-processor/requirements.txt index b44281a..5198e6f 100644 --- a/src/file-processor/requirements.txt +++ b/src/file-processor/requirements.txt @@ -5,8 +5,9 @@ email-validator==2.3.0 fastapi==0.116.1 httptools==0.6.4 motor==3.7.1 -pymongo==4.15.0 pydantic==2.11.9 +PyJWT==2.10.1 +pymongo==4.15.0 redis==6.4.0 uvicorn==0.35.0 python-magic==0.4.27 diff --git a/src/worker/tasks/main.py b/src/worker/tasks/main.py index 4dbf92c..f76c202 100644 --- a/src/worker/tasks/main.py +++ b/src/worker/tasks/main.py @@ -28,8 +28,8 @@ celery_app.conf.update( timezone="UTC", enable_utc=True, task_track_started=True, - task_time_limit=300, # 5 minutes - task_soft_time_limit=240, # 4 minutes + task_time_limit=300, # 5 minutes + task_soft_time_limit=240, # 4 minutes ) if __name__ == "__main__": diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/test_auth_routes.py b/tests/api/test_auth_routes.py new file mode 100644 index 0000000..dd63377 --- /dev/null +++ b/tests/api/test_auth_routes.py @@ -0,0 +1,139 @@ +from unittest.mock import MagicMock + +import pytest +from fastapi import status, HTTPException +from fastapi.testclient import TestClient + +from app.main import app # Assuming you have FastAPI app defined in app/main.py +from app.models.auth import UserRole +from app.models.types import PyObjectId +from app.models.user import UserInDB +from app.services.auth_service import AuthService +from app.services.user_service import UserService + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture +def fake_user(): + return UserInDB( + _id=PyObjectId(), + username="testuser", + email="test@example.com", + role=UserRole.USER, + is_active=True, + hashed_password="hashed-secret", + created_at="2023-01-01T00:00:00", + updated_at="2023-01-01T00:00:00", + ) + + +def override_auth_service(): + mock = MagicMock(spec=AuthService) + mock.verify_user_password.return_value = True + mock.create_access_token.return_value = "fake-jwt-token" + return mock + + +def override_user_service(fake_user): + mock = MagicMock(spec=UserService) + mock.get_user_by_username.return_value = fake_user + return mock + + +def override_get_current_user(fake_user): + def _override(): + return fake_user + + return _override + + +# ---------------------- TESTS FOR /auth/login ---------------------- +class TestLogin: + def test_i_can_login_with_valid_credentials(self, client, fake_user, monkeypatch): + auth_service = override_auth_service() + user_service = override_user_service(fake_user) + + monkeypatch.setattr("app.api.routes.auth.get_auth_service", lambda: auth_service) + monkeypatch.setattr("app.api.routes.auth.get_user_service", lambda: user_service) + + response = client.post( + "/auth/login", + data={"username": "testuser", "password": "secret"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert "access_token" in data + assert data["user"]["username"] == "testuser" + + def test_i_cannot_login_with_invalid_username(self, client, monkeypatch): + auth_service = override_auth_service() + user_service = MagicMock(spec=UserService) + user_service.get_user_by_username.return_value = None + + monkeypatch.setattr("app.api.routes.auth.get_auth_service", lambda: auth_service) + monkeypatch.setattr("app.api.routes.auth.get_user_service", lambda: user_service) + + response = client.post( + "/auth/login", + data={"username": "unknown", "password": "secret"}, + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_i_cannot_login_with_inactive_user(self, client, fake_user, monkeypatch): + fake_user.is_active = False + auth_service = override_auth_service() + user_service = override_user_service(fake_user) + + monkeypatch.setattr("app.api.routes.auth.get_auth_service", lambda: auth_service) + monkeypatch.setattr("app.api.routes.auth.get_user_service", lambda: user_service) + + response = client.post( + "/auth/login", + data={"username": "testuser", "password": "secret"}, + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_i_cannot_login_with_wrong_password(self, client, fake_user, monkeypatch): + auth_service = override_auth_service() + auth_service.verify_user_password.return_value = False + user_service = override_user_service(fake_user) + + monkeypatch.setattr("app.api.routes.auth.get_auth_service", lambda: auth_service) + monkeypatch.setattr("app.api.routes.auth.get_user_service", lambda: user_service) + + response = client.post( + "/auth/login", + data={"username": "testuser", "password": "wrong"}, + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +# ---------------------- TESTS FOR /auth/me ---------------------- +class TesteMe: + def test_i_can_get_current_user_profile(self, client, fake_user, monkeypatch): + monkeypatch.setattr("app.api.routes.auth.get_current_user", override_get_current_user(fake_user)) + + response = client.get("/auth/me") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["username"] == fake_user.username + assert data["email"] == fake_user.email + + def test_i_cannot_get_profile_without_authentication(self, client, monkeypatch): + def raise_http_exception(): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + monkeypatch.setattr("app.api.routes.auth.get_current_user", raise_http_exception) + + response = client.get("/auth/me") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/tests/models/test_user_models.py b/tests/models/test_user_models.py index a11ff1e..d199fa4 100644 --- a/tests/models/test_user_models.py +++ b/tests/models/test_user_models.py @@ -10,8 +10,8 @@ from pydantic import ValidationError from datetime import datetime from bson import ObjectId -from app.models.user import UserCreate, UserUpdate, UserInDB, UserResponse -from app.models.auth import UserRole +from app.models.user import UserCreate, UserUpdate, UserInDB +from app.models.auth import UserRole, UserResponse class TestUserCreateModel: @@ -349,7 +349,7 @@ class TestUserResponseModel: # Convert to response model (excluding password_hash) user_response = UserResponse( - id=user_in_db.id, + _id=user_in_db.id, username=user_in_db.username, email=user_in_db.email, role=user_in_db.role,