diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4ea3d63..e44ca60 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,8 @@ on: branches: [main, develop] jobs: - test: + unit-tests: + name: Unit Tests runs-on: ubuntu-latest steps: @@ -22,8 +23,9 @@ jobs: uses: actions/cache@v3 with: path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }} + key: ${{ runner.os }}-pip-unit-${{ hashFiles('pyproject.toml') }} restore-keys: | + ${{ runner.os }}-pip-unit- ${{ runner.os }}-pip- - name: Create and activate virtual environment @@ -37,7 +39,57 @@ jobs: pip install --upgrade pip pip install . - - name: Run tests + - name: Run unit tests + run: | + source venv/bin/activate + pytest src/domain/tests/ \ + -v \ + --tb=short \ + --cov=src/domain/services \ + --cov=src/domain/lib \ + --cov-report=term-missing + + - name: Unit Tests Summary + if: always() + run: | + echo "Unit tests completed!" + echo "Check the logs above for details." + + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + needs: unit-tests + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Cache pip dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-integration-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip-integration- + ${{ runner.os }}-pip- + + - name: Create and activate virtual environment + run: | + python -m venv venv + source venv/bin/activate + + - name: Install dependencies + run: | + source venv/bin/activate + pip install --upgrade pip + pip install . + + - name: Start application server env: ENV: test SECRET_KEY: test_secret_key @@ -45,6 +97,15 @@ jobs: run: | source venv/bin/activate uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload & + sleep 10 # Attendre que le serveur soit prêt + + - name: Run integration tests + env: + ENV: test + SECRET_KEY: test_secret_key + ACCESS_TOKEN_EXPIRE_MINUTES: 60 + run: | + source venv/bin/activate pytest src/entrypoints/api/tests/profile.py \ src/entrypoints/api/tests/group.py \ src/entrypoints/api/tests/exercise.py \ @@ -53,8 +114,8 @@ jobs: -v \ --tb=short - - name: Test Summary + - name: Integration Tests Summary if: always() run: | - echo "Tests completed!" - echo "Check the logs above for details." + echo "Integration tests completed!" + echo "Check the logs above for details." \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 40bb75f..4dbc6cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,52 @@ name = "tracknatrainapi" version = "0.5.9" requires-python = ">=3.12" -dependencies = [ "annotated-types==0.7.0", "anyio==4.9.0", "bcrypt==4.3.0", "boto3==1.37.37", "botocore==1.37.37", "cffi==1.17.1", "click==8.1.8", "cryptography==44.0.2", "dnspython==2.7.0", "ecdsa==0.19.1", "email-validator==2.2.0", "exceptiongroup==1.2.2", "fastapi==0.115.12", "greenlet==3.1.1", "h11==0.14.0", "idna==3.10", "jmespath==1.0.1", "passlib[bcrypt]>=1.7.4", "psycopg2-binary==2.9.10", "pyasn1==0.4.8", "pycparser==2.22", "pydantic==2.11.3", "pydantic-core==2.33.1", "python-dateutil==2.9.0.post0", "python-dotenv==1.1.0", "python-jose==3.4.0", "python-multipart==0.0.20", "rsa==4.9", "s3transfer==0.11.5", "six==1.17.0", "sniffio==1.3.1", "sqlalchemy==2.0.40", "starlette==0.46.2", "typing-extensions==4.13.2", "typing-inspection==0.4.0", "urllib3==2.4.0", "uvicorn==0.34.1", "pytest>=7.0", "pytest-asyncio>=0.20", "httpx>=0.24", "pytest-cov>=4.0", "coverage>=6.0", "asyncpg==0.30.0",] +dependencies = [ + "annotated-types==0.7.0", + "anyio==4.9.0", + "bcrypt==4.3.0", + "boto3==1.37.37", + "botocore==1.37.37", + "cffi==1.17.1", + "click==8.1.8", + "cryptography==44.0.2", + "dnspython==2.7.0", + "ecdsa==0.19.1", + "email-validator==2.2.0", + "exceptiongroup==1.2.2", + "fastapi==0.115.12", + "greenlet==3.1.1", + "h11==0.14.0", + "idna==3.10", + "jmespath==1.0.1", + "passlib[bcrypt]>=1.7.4", + "psycopg2-binary==2.9.10", + "pyasn1==0.4.8", + "pycparser==2.22", + "pydantic==2.11.3", + "pydantic-core==2.33.1", + "python-dateutil==2.9.0.post0", + "python-dotenv==1.1.0", + "python-jose==3.4.0", + "python-multipart==0.0.20", + "rsa==4.9", + "s3transfer==0.11.5", + "six==1.17.0", + "sniffio==1.3.1", + "sqlalchemy==2.0.40", + "starlette==0.46.2", + "typing-extensions==4.13.2", + "typing-inspection==0.4.0", + "urllib3==2.4.0", + "uvicorn==0.34.1", + "pytest>=7.0", + "pytest-asyncio>=0.20", + "httpx>=0.24", + "pytest-cov>=4.0", + "coverage>=6.0", + "asyncpg==0.30.0", + "pytest-mock>=3.10,<4.0", +] [build-system] requires = [ "setuptools>=42", "wheel",] diff --git a/src/domain/lib/security.py b/src/domain/lib/security.py index 0f14cc4..f3b030c 100644 --- a/src/domain/lib/security.py +++ b/src/domain/lib/security.py @@ -1,4 +1,5 @@ from passlib.context import CryptContext +from passlib.exc import UnknownHashError from src.domain.ports.password_hasher import PasswordHasher pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -8,4 +9,7 @@ def hash(self, plain: str) -> str: return pwd_context.hash(plain) def verify(self, plain: str, hashed: str) -> bool: - return pwd_context.verify(plain, hashed) \ No newline at end of file + try: + return pwd_context.verify(plain, hashed) + except UnknownHashError: + return False \ No newline at end of file diff --git a/src/domain/tests/__init__.py b/src/domain/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/domain/tests/test_bcrypt_password_hasher.py b/src/domain/tests/test_bcrypt_password_hasher.py new file mode 100644 index 0000000..2beebd4 --- /dev/null +++ b/src/domain/tests/test_bcrypt_password_hasher.py @@ -0,0 +1,144 @@ +import pytest +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) + + +from src.domain.lib.security import BcryptPasswordHasher + + +@pytest.fixture +def password_hasher(): + return BcryptPasswordHasher() + + +def test_hash_password(password_hasher): + """Test que le hachage d'un mot de passe fonctionne""" + password = "test_password_123" + + hashed = password_hasher.hash(password) + + # Vérifier que le hash est généré et différent du mot de passe original + assert hashed is not None + assert hashed != password + assert len(hashed) > 0 + # Bcrypt hashes commencent généralement par $2b$ + assert hashed.startswith("$2b$") + + +def test_hash_different_passwords_produce_different_hashes(password_hasher): + """Test que des mots de passe différents produisent des hashes différents""" + password1 = "password123" + password2 = "password456" + + hash1 = password_hasher.hash(password1) + hash2 = password_hasher.hash(password2) + + assert hash1 != hash2 + + +def test_hash_same_password_produces_different_salts(password_hasher): + """Test que le même mot de passe produit des hashes différents (à cause du salt)""" + password = "same_password" + + hash1 = password_hasher.hash(password) + hash2 = password_hasher.hash(password) + + # Les hashes doivent être différents à cause du salt aléatoire + assert hash1 != hash2 + + +def test_verify_correct_password(password_hasher): + """Test que la vérification d'un mot de passe correct fonctionne""" + password = "correct_password" + + hashed = password_hasher.hash(password) + result = password_hasher.verify(password, hashed) + + assert result is True + + +def test_verify_incorrect_password(password_hasher): + """Test que la vérification d'un mot de passe incorrect échoue""" + correct_password = "correct_password" + wrong_password = "wrong_password" + + hashed = password_hasher.hash(correct_password) + result = password_hasher.verify(wrong_password, hashed) + + assert result is False + + +def test_verify_empty_password(password_hasher): + """Test que la vérification avec un mot de passe vide échoue""" + password = "non_empty_password" + + hashed = password_hasher.hash(password) + result = password_hasher.verify("", hashed) + + assert result is False + + +def test_verify_with_invalid_hash(password_hasher): + """Test que la vérification avec un hash invalide retourne False au lieu de lever une exception""" + password = "test_password" + invalid_hash = "invalid_hash_string" + + # Au lieu de lever une exception, on s'attend à ce que verify retourne False + result = password_hasher.verify(password, invalid_hash) + + assert result is False + + +def test_hash_empty_string(password_hasher): + """Test que le hachage d'une chaîne vide fonctionne""" + empty_password = "" + + hashed = password_hasher.hash(empty_password) + + assert hashed is not None + assert len(hashed) > 0 + assert hashed.startswith("$2b$") + + +def test_verify_empty_string_with_its_hash(password_hasher): + """Test que la vérification d'une chaîne vide avec son propre hash fonctionne""" + empty_password = "" + + hashed = password_hasher.hash(empty_password) + result = password_hasher.verify(empty_password, hashed) + + assert result is True + + +def test_hash_special_characters(password_hasher): + """Test que le hachage de caractères spéciaux fonctionne""" + special_password = "pàssw0rd!@#$%^&*()_+-=[]{}|;':\",./<>?" + + hashed = password_hasher.hash(special_password) + result = password_hasher.verify(special_password, hashed) + + assert hashed is not None + assert result is True + + +def test_hash_unicode_characters(password_hasher): + """Test que le hachage de caractères Unicode fonctionne""" + unicode_password = "пароль123🔒" + + hashed = password_hasher.hash(unicode_password) + result = password_hasher.verify(unicode_password, hashed) + + assert hashed is not None + assert result is True + + +def test_hash_long_password(password_hasher): + """Test que le hachage d'un mot de passe très long fonctionne""" + long_password = "a" * 1000 # Mot de passe de 1000 caractères + + hashed = password_hasher.hash(long_password) + result = password_hasher.verify(long_password, hashed) + + assert hashed is not None + assert result is True \ No newline at end of file diff --git a/src/domain/tests/test_diet_service.py b/src/domain/tests/test_diet_service.py new file mode 100644 index 0000000..e27bbe8 --- /dev/null +++ b/src/domain/tests/test_diet_service.py @@ -0,0 +1,550 @@ +import pytest +from uuid import uuid4, UUID +from datetime import datetime +from unittest.mock import AsyncMock +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) + +from src.domain.services.diet import DietService +from src.domain.ports.diet_repository import DietRepository +from src.domain.model.diet import Diet as DomainDiet, MacroPlan as DomainMacroPlan, MealPlan as DomainMealPlan, MealItem as DomainMealItem +from src.domain.exceptions import NotFoundError + + +# Fixture pour simuler le repository (DietRepository) +@pytest.fixture +def mock_repo(): + repo = AsyncMock(spec=DietRepository) + return repo + + +# Fixture pour créer une instance de DietService avec le mock +@pytest.fixture +def diet_service(mock_repo): + return DietService(repo=mock_repo) + + +# Helper functions pour créer des objets de test +def create_test_diet(diet_id=None, owner_id=None, name="Test Diet"): + return DomainDiet( + id=diet_id or uuid4(), + owner_id=owner_id or uuid4(), + name=name, + description="Test description", + created_at=datetime.utcnow() + ) + + +def create_test_macro_plan(plan_id=None, diet_id=None, name="Test Macro Plan"): + return DomainMacroPlan( + id=plan_id or uuid4(), + diet_id=diet_id or uuid4(), + name=name, + carbohydrates=100.0, + lipids=50.0, + protein=75.0, + fiber=25.0, + water=2000.0, + kilocalorie=1800.0 + ) + + +def create_test_meal_item(timing="breakfast", food="eggs"): + # Correction : utiliser les bons paramètres selon le modèle + return DomainMealItem( + timing=timing, + food=food + ) + + +def create_test_meal_plan(plan_id=None, diet_id=None, name="Test Meal Plan"): + meals = [ + create_test_meal_item("breakfast", "eggs and toast"), + create_test_meal_item("lunch", "chicken salad") + ] + return DomainMealPlan( + id=plan_id or uuid4(), + diet_id=diet_id or uuid4(), + name=name, + meals=meals + ) + + +# Tests pour Diet +@pytest.mark.asyncio +async def test_create_diet_success(diet_service, mock_repo): + owner_id = uuid4() + name = "Mediterranean Diet" + description = "Healthy Mediterranean diet plan" + + created_diet = create_test_diet(owner_id=owner_id, name=name) + mock_repo.add_diet.return_value = created_diet + + result = await diet_service.create_diet( + owner_id=owner_id, + name=name, + description=description + ) + + mock_repo.add_diet.assert_called_once() + assert result == created_diet + + +@pytest.mark.asyncio +async def test_create_diet_empty_name(diet_service): + owner_id = uuid4() + + with pytest.raises(ValueError, match="Diet name cannot be empty"): + await diet_service.create_diet( + owner_id=owner_id, + name="" + ) + + +@pytest.mark.asyncio +async def test_create_diet_without_description(diet_service, mock_repo): + owner_id = uuid4() + name = "Keto Diet" + + created_diet = create_test_diet(owner_id=owner_id, name=name) + mock_repo.add_diet.return_value = created_diet + + result = await diet_service.create_diet( + owner_id=owner_id, + name=name + ) + + mock_repo.add_diet.assert_called_once() + assert result == created_diet + + +@pytest.mark.asyncio +async def test_get_diet_success(diet_service, mock_repo): + diet_id = uuid4() + diet = create_test_diet(diet_id=diet_id) + + mock_repo.find_by_id.return_value = diet + + result = await diet_service.get_diet(diet_id) + + mock_repo.find_by_id.assert_called_once_with(diet_id) + assert result == diet + + +@pytest.mark.asyncio +async def test_get_diet_not_found(diet_service, mock_repo): + diet_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Diet {diet_id} not found"): + await diet_service.get_diet(diet_id) + + +@pytest.mark.asyncio +async def test_list_owner_diets(diet_service, mock_repo): + owner_id = uuid4() + diets = [ + create_test_diet(owner_id=owner_id), + create_test_diet(owner_id=owner_id) + ] + + mock_repo.find_all_owner_diets.return_value = diets + + result = await diet_service.list_owner_diets(owner_id) + + mock_repo.find_all_owner_diets.assert_called_once_with(owner_id) + assert result == diets + + +@pytest.mark.asyncio +async def test_update_diet_success(diet_service, mock_repo): + diet_id = uuid4() + diet = create_test_diet(diet_id=diet_id) + updated_diet = create_test_diet(diet_id=diet_id) + updated_diet.name = "Updated Diet" + updated_diet.description = "Updated description" + + mock_repo.find_by_id.return_value = diet + mock_repo.update_diet.return_value = updated_diet + + result = await diet_service.update_diet( + diet_id=diet_id, + name="Updated Diet", + description="Updated description" + ) + + mock_repo.find_by_id.assert_called_once_with(diet_id) + assert diet.name == "Updated Diet" + assert diet.description == "Updated description" + mock_repo.update_diet.assert_called_once_with(diet) + assert result == updated_diet + + +@pytest.mark.asyncio +async def test_update_diet_not_found(diet_service, mock_repo): + diet_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Diet {diet_id} not found"): + await diet_service.update_diet(diet_id, name="New Name") + + +@pytest.mark.asyncio +async def test_update_diet_partial(diet_service, mock_repo): + diet_id = uuid4() + diet = create_test_diet(diet_id=diet_id) + original_description = diet.description + + mock_repo.find_by_id.return_value = diet + mock_repo.update_diet.return_value = diet + + await diet_service.update_diet( + diet_id=diet_id, + name="New Name Only" + ) + + assert diet.name == "New Name Only" + assert diet.description == original_description # Description inchangée + + +@pytest.mark.asyncio +async def test_delete_diet_success(diet_service, mock_repo): + diet_id = uuid4() + diet = create_test_diet(diet_id=diet_id) + + mock_repo.find_by_id.return_value = diet + + await diet_service.delete_diet(diet_id) + + mock_repo.find_by_id.assert_called_once_with(diet_id) + mock_repo.delete_diet.assert_called_once_with(diet_id) + + +@pytest.mark.asyncio +async def test_delete_diet_not_found(diet_service, mock_repo): + diet_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Diet {diet_id} not found"): + await diet_service.delete_diet(diet_id) + + +# Tests pour MacroPlan +@pytest.mark.asyncio +async def test_create_macro_plan_success(diet_service, mock_repo): + diet_id = uuid4() + name = "High Protein Plan" + + created_plan = create_test_macro_plan(diet_id=diet_id, name=name) + mock_repo.add_macro_plan.return_value = created_plan + + result = await diet_service.create_macro_plan( + diet_id=diet_id, + name=name, + carbohydrates=100.0, + lipids=50.0, + protein=75.0, + fiber=25.0, + water=2000.0, + kilocalorie=1800.0 + ) + + mock_repo.add_macro_plan.assert_called_once() + assert result == created_plan + + +@pytest.mark.asyncio +async def test_create_macro_plan_empty_name(diet_service): + diet_id = uuid4() + + with pytest.raises(ValueError, match="Name is required"): + await diet_service.create_macro_plan( + diet_id=diet_id, + name="", + carbohydrates=100.0, + lipids=50.0, + protein=75.0, + fiber=25.0, + water=2000.0, + kilocalorie=1800.0 + ) + + +@pytest.mark.asyncio +async def test_get_macro_plans_for_diet(diet_service, mock_repo): + diet_id = uuid4() + plans = [ + create_test_macro_plan(diet_id=diet_id), + create_test_macro_plan(diet_id=diet_id) + ] + + mock_repo.find_macro_plans_by_diet_id.return_value = plans + + result = await diet_service.get_macro_plans_for_diet(diet_id) + + mock_repo.find_macro_plans_by_diet_id.assert_called_once_with(diet_id) + assert result == plans + + +@pytest.mark.asyncio +async def test_get_macro_plans_by_user_id(diet_service, mock_repo): + user_id = uuid4() + plans = [create_test_macro_plan(), create_test_macro_plan()] + + mock_repo.find_macro_plans_by_user_id.return_value = plans + + result = await diet_service.get_macro_plans_by_user_id(user_id) + + mock_repo.find_macro_plans_by_user_id.assert_called_once_with(user_id) + assert result == plans + + +@pytest.mark.asyncio +async def test_get_macro_plan_success(diet_service, mock_repo): + plan_id = uuid4() + plan = create_test_macro_plan(plan_id=plan_id) + + mock_repo.find_macro_plan_by_id.return_value = plan + + result = await diet_service.get_macro_plan(plan_id) + + mock_repo.find_macro_plan_by_id.assert_called_once_with(plan_id) + assert result == plan + + +@pytest.mark.asyncio +async def test_get_macro_plan_not_found(diet_service, mock_repo): + plan_id = uuid4() + + mock_repo.find_macro_plan_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"MacroPlan {plan_id} not found"): + await diet_service.get_macro_plan(plan_id) + + +@pytest.mark.asyncio +async def test_update_macro_plan_success(diet_service, mock_repo): + plan_id = uuid4() + plan = create_test_macro_plan(plan_id=plan_id) + updated_plan = create_test_macro_plan(plan_id=plan_id) + + mock_repo.find_macro_plan_by_id.return_value = plan + mock_repo.update_macro_plan.return_value = updated_plan + + result = await diet_service.update_macro_plan( + plan_id=plan_id, + name="Updated Plan", + protein=100.0, + kilocalorie=2000.0 + ) + + mock_repo.find_macro_plan_by_id.assert_called_once_with(plan_id) + assert plan.name == "Updated Plan" + assert plan.protein == 100.0 + assert plan.kilocalorie == 2000.0 + mock_repo.update_macro_plan.assert_called_once_with(plan) + assert result == updated_plan + + +@pytest.mark.asyncio +async def test_delete_macro_plan_success(diet_service, mock_repo): + plan_id = uuid4() + plan = create_test_macro_plan(plan_id=plan_id) + + mock_repo.find_macro_plan_by_id.return_value = plan + + await diet_service.delete_macro_plan(plan_id) + + mock_repo.find_macro_plan_by_id.assert_called_once_with(plan_id) + mock_repo.delete_macro_plan.assert_called_once_with(plan_id) + + +@pytest.mark.asyncio +async def test_delete_macro_plan_not_found(diet_service, mock_repo): + plan_id = uuid4() + + mock_repo.find_macro_plan_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"MacroPlan {plan_id} not found"): + await diet_service.delete_macro_plan(plan_id) + + +# Tests pour MealPlan +@pytest.mark.asyncio +async def test_create_meal_plan_success(diet_service, mock_repo): + diet_id = uuid4() + name = "Daily Meal Plan" + + # Mock des objets meals pour simuler le .model_dump() + class MockMeal: + def model_dump(self): + return {"timing": "breakfast", "food": "eggs and toast"} + + meals = [MockMeal(), MockMeal()] + created_plan = create_test_meal_plan(diet_id=diet_id, name=name) + mock_repo.add_meal_plan.return_value = created_plan + + result = await diet_service.create_meal_plan( + diet_id=diet_id, + name=name, + meals=meals + ) + + mock_repo.add_meal_plan.assert_called_once() + assert result == created_plan + + +@pytest.mark.asyncio +async def test_create_meal_plan_empty_name(diet_service): + diet_id = uuid4() + + class MockMeal: + def model_dump(self): + return {"timing": "breakfast", "food": "eggs and toast"} + + meals = [MockMeal()] + + with pytest.raises(ValueError, match="Meal Plan name cannot be empty"): + await diet_service.create_meal_plan( + diet_id=diet_id, + name="", + meals=meals + ) + + +@pytest.mark.asyncio +async def test_get_meal_plan_by_id_success(diet_service, mock_repo): + plan_id = uuid4() + plan = create_test_meal_plan(plan_id=plan_id) + + mock_repo.find_meal_plan_by_id.return_value = plan + + result = await diet_service.get_meal_plan_by_id(plan_id) + + mock_repo.find_meal_plan_by_id.assert_called_once_with(plan_id) + assert result == plan + + +@pytest.mark.asyncio +async def test_get_meal_plan_by_id_not_found(diet_service, mock_repo): + plan_id = uuid4() + + mock_repo.find_meal_plan_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"MealPlan {plan_id} not found"): + await diet_service.get_meal_plan_by_id(plan_id) + + +@pytest.mark.asyncio +async def test_get_meal_plans_by_diet(diet_service, mock_repo): + diet_id = uuid4() + plans = [ + create_test_meal_plan(diet_id=diet_id), + create_test_meal_plan(diet_id=diet_id) + ] + + mock_repo.find_meal_plans_by_diet_id.return_value = plans + + result = await diet_service.get_meal_plans_by_diet(diet_id) + + mock_repo.find_meal_plans_by_diet_id.assert_called_once_with(diet_id) + assert result == plans + + +@pytest.mark.asyncio +async def test_get_meal_plans_by_user(diet_service, mock_repo): + user_id = uuid4() + plans = [create_test_meal_plan(), create_test_meal_plan()] + + mock_repo.find_meal_plans_by_user_id.return_value = plans + + result = await diet_service.get_meal_plans_by_user(user_id) + + mock_repo.find_meal_plans_by_user_id.assert_called_once_with(user_id) + assert result == plans + + +@pytest.mark.asyncio +async def test_update_meal_plan_success(diet_service, mock_repo): + plan_id = uuid4() + plan = create_test_meal_plan(plan_id=plan_id) + updated_plan = create_test_meal_plan(plan_id=plan_id) + + class MockMeal: + def model_dump(self): + return {"timing": "dinner", "food": "salmon and rice"} + + new_meals = [MockMeal()] + + mock_repo.find_meal_plan_by_id.return_value = plan + mock_repo.update_meal_plan.return_value = updated_plan + + result = await diet_service.update_meal_plan( + plan_id=plan_id, + name="Updated Meal Plan", + meals=new_meals + ) + + mock_repo.find_meal_plan_by_id.assert_called_once_with(plan_id) + assert plan.name == "Updated Meal Plan" + mock_repo.update_meal_plan.assert_called_once_with(plan) + assert result == updated_plan + + +@pytest.mark.asyncio +async def test_update_meal_plan_empty_meals(diet_service, mock_repo): + plan_id = uuid4() + plan = create_test_meal_plan(plan_id=plan_id) + + mock_repo.find_meal_plan_by_id.return_value = plan + + with pytest.raises(ValueError, match="Meal Plan must have at least one meal"): + await diet_service.update_meal_plan( + plan_id=plan_id, + meals=[] + ) + + +@pytest.mark.asyncio +async def test_update_meal_plan_partial(diet_service, mock_repo): + plan_id = uuid4() + plan = create_test_meal_plan(plan_id=plan_id) + original_meals = plan.meals + + mock_repo.find_meal_plan_by_id.return_value = plan + mock_repo.update_meal_plan.return_value = plan + + await diet_service.update_meal_plan( + plan_id=plan_id, + name="New Name Only" + ) + + assert plan.name == "New Name Only" + assert plan.meals == original_meals # Meals inchangés + + +@pytest.mark.asyncio +async def test_delete_meal_plan_success(diet_service, mock_repo): + plan_id = uuid4() + plan = create_test_meal_plan(plan_id=plan_id) + + mock_repo.find_meal_plan_by_id.return_value = plan + + await diet_service.delete_meal_plan(plan_id) + + mock_repo.find_meal_plan_by_id.assert_called_once_with(plan_id) + mock_repo.delete_meal_plan.assert_called_once_with(plan_id) + + +@pytest.mark.asyncio +async def test_delete_meal_plan_not_found(diet_service, mock_repo): + plan_id = uuid4() + + mock_repo.find_meal_plan_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"MealPlan {plan_id} not found"): + await diet_service.delete_meal_plan(plan_id) \ No newline at end of file diff --git a/src/domain/tests/test_exercise_service.py b/src/domain/tests/test_exercise_service.py new file mode 100644 index 0000000..0af996b --- /dev/null +++ b/src/domain/tests/test_exercise_service.py @@ -0,0 +1,229 @@ +import pytest +from uuid import uuid4, UUID +from datetime import datetime +from unittest.mock import AsyncMock +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) + +from src.domain.services.exercise import ExerciseService +from src.domain.ports.exercise_repository import ExerciseRepository +from src.domain.model.exercise import Exercise as DomainExercise +from src.domain.exceptions import NotFoundError + + +# Fixture pour simuler le repository (ExerciseRepository) +@pytest.fixture +def mock_repo(): + repo = AsyncMock(spec=ExerciseRepository) + return repo + + +# Fixture pour créer une instance de ExerciseService avec le mock +@pytest.fixture +def exercise_service(mock_repo): + return ExerciseService(repo=mock_repo) + + +# Helper function pour créer un exercice de test +def create_test_exercise(exercise_id=None, owner_id=None, name="Test Exercise"): + return DomainExercise( + id=exercise_id or uuid4(), + owner_id=owner_id or uuid4(), + name=name, + description="Test description", + created_at=datetime.utcnow() + ) + + +# Test de création d'exercice avec succès +@pytest.mark.asyncio +async def test_create_exercise_success(exercise_service, mock_repo): + owner_id = uuid4() + name = "Push-ups" + description = "Standard push-ups exercise" + + created_exercise = create_test_exercise(owner_id=owner_id, name=name) + mock_repo.add.return_value = created_exercise + + result = await exercise_service.create_exercise( + owner_id=owner_id, + name=name, + description=description + ) + + mock_repo.add.assert_called_once() + assert result == created_exercise + + +# Test de création d'exercice avec nom vide +@pytest.mark.asyncio +async def test_create_exercise_empty_name(exercise_service): + owner_id = uuid4() + + with pytest.raises(ValueError, match="Exercise name cannot be empty"): + await exercise_service.create_exercise( + owner_id=owner_id, + name="" + ) + + +# Test de création d'exercice sans description +@pytest.mark.asyncio +async def test_create_exercise_without_description(exercise_service, mock_repo): + owner_id = uuid4() + name = "Pull-ups" + + created_exercise = create_test_exercise(owner_id=owner_id, name=name) + mock_repo.add.return_value = created_exercise + + result = await exercise_service.create_exercise( + owner_id=owner_id, + name=name + ) + + mock_repo.add.assert_called_once() + assert result == created_exercise + + +# Test de suppression d'exercice +@pytest.mark.asyncio +async def test_delete_exercise(exercise_service, mock_repo): + exercise_id = uuid4() + + await exercise_service.delete_exercise(exercise_id) + + mock_repo.delete.assert_called_once_with(exercise_id) + + +# Test de mise à jour d'exercice avec succès +@pytest.mark.asyncio +async def test_update_exercise_success(exercise_service, mock_repo): + exercise_id = uuid4() + exercise = create_test_exercise(exercise_id=exercise_id) + updated_exercise = create_test_exercise(exercise_id=exercise_id) + updated_exercise.name = "Updated Exercise" + updated_exercise.description = "Updated description" + + mock_repo.find_by_id.return_value = exercise + mock_repo.update.return_value = updated_exercise + + result = await exercise_service.update_exercise( + exercise_id=exercise_id, + name="Updated Exercise", + description="Updated description" + ) + + mock_repo.find_by_id.assert_called_once_with(exercise_id) + assert exercise.name == "Updated Exercise" + assert exercise.description == "Updated description" + mock_repo.update.assert_called_once_with(exercise) + assert result == updated_exercise + + +# Test de mise à jour d'exercice - exercice non trouvé +@pytest.mark.asyncio +async def test_update_exercise_not_found(exercise_service, mock_repo): + exercise_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Exercise {exercise_id} not found"): + await exercise_service.update_exercise( + exercise_id=exercise_id, + name="New Name" + ) + + +# Test de mise à jour partielle d'exercice +@pytest.mark.asyncio +async def test_update_exercise_partial(exercise_service, mock_repo): + exercise_id = uuid4() + exercise = create_test_exercise(exercise_id=exercise_id) + original_description = exercise.description + + mock_repo.find_by_id.return_value = exercise + mock_repo.update.return_value = exercise + + await exercise_service.update_exercise( + exercise_id=exercise_id, + name="New Name Only" + ) + + assert exercise.name == "New Name Only" + assert exercise.description == original_description # Description inchangée + + +# Test de récupération des exercices du propriétaire avec succès +@pytest.mark.asyncio +async def test_get_exercises_mine_success(exercise_service, mock_repo): + owner_id = uuid4() + exercises = [ + create_test_exercise(owner_id=owner_id), + create_test_exercise(owner_id=owner_id) + ] + + mock_repo.find_all_owner.return_value = exercises + + result = await exercise_service.get_exercises_mine(owner_id) + + mock_repo.find_all_owner.assert_called_once_with(owner_id) + assert result == exercises + + +# Test de récupération des exercices du propriétaire - aucun exercice +@pytest.mark.asyncio +async def test_get_exercises_mine_not_found(exercise_service, mock_repo): + owner_id = uuid4() + + mock_repo.find_all_owner.return_value = [] + + with pytest.raises(NotFoundError, match=f"No exercises found for owner {owner_id}"): + await exercise_service.get_exercises_mine(owner_id) + + +# Test de récupération de tous les exercices avec succès +@pytest.mark.asyncio +async def test_get_all_exercises_success(exercise_service, mock_repo): + exercises = [create_test_exercise(), create_test_exercise()] + + mock_repo.find_all.return_value = exercises + + result = await exercise_service.get_all_exercises() + + mock_repo.find_all.assert_called_once() + assert result == exercises + + +# Test de récupération de tous les exercices - aucun exercice +@pytest.mark.asyncio +async def test_get_all_exercises_not_found(exercise_service, mock_repo): + mock_repo.find_all.return_value = [] + + with pytest.raises(NotFoundError, match="No exercises found"): + await exercise_service.get_all_exercises() + + +# Test de récupération d'exercice par ID avec succès +@pytest.mark.asyncio +async def test_get_by_id_success(exercise_service, mock_repo): + exercise_id = uuid4() + exercise = create_test_exercise(exercise_id=exercise_id) + + mock_repo.find_by_id.return_value = exercise + + result = await exercise_service.get_by_id(exercise_id) + + mock_repo.find_by_id.assert_called_once_with(exercise_id) + assert result == exercise + + +# Test de récupération d'exercice par ID - non trouvé +@pytest.mark.asyncio +async def test_get_by_id_not_found(exercise_service, mock_repo): + exercise_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Exercise with id {exercise_id} not found"): + await exercise_service.get_by_id(exercise_id) \ No newline at end of file diff --git a/src/domain/tests/test_group_service.py b/src/domain/tests/test_group_service.py new file mode 100644 index 0000000..0a6ed6b --- /dev/null +++ b/src/domain/tests/test_group_service.py @@ -0,0 +1,440 @@ +import pytest +from uuid import uuid4, UUID +from datetime import datetime +from unittest.mock import AsyncMock, patch +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) + +from src.domain.services.group import GroupService +from src.domain.ports.group_repository import GroupRepository +from src.domain.model.group import Group as DomainGroup +from src.domain.model.profile import Profile as DomainProfile +from src.domain.exceptions import NotFoundError + + +# Fixture pour simuler le repository (GroupRepository) +@pytest.fixture +def mock_repo(): + repo = AsyncMock(spec=GroupRepository) + return repo + + +# Fixture pour créer une instance de GroupService avec le mock +@pytest.fixture +def group_service(mock_repo): + return GroupService(repo=mock_repo) + + +# Helper function pour créer un groupe de test +def create_test_group(group_id=None, owner_id=None, name="Test Group"): + return DomainGroup( + id=group_id or uuid4(), + owner_id=owner_id or uuid4(), + name=name, + description="Test description", + created_at=datetime.utcnow() + ) + + +# Helper function pour créer un profil de test +def create_test_profile(profile_id=None, roles=None): + profile = DomainProfile( + id=profile_id or uuid4(), + email="test@example.com", + password="hashedpassword", + name="Test User", + roles=roles or ["user"] + ) + return profile + + +# Test de création de groupe avec succès +@pytest.mark.asyncio +async def test_create_group_success(group_service, mock_repo): + owner_id = uuid4() + name = "New Group" + description = "Group description" + + # Simuler le retour du repository + created_group = create_test_group(owner_id=owner_id, name=name) + mock_repo.add.return_value = created_group + + result = await group_service.create( + owner_id=owner_id, + name=name, + description=description + ) + + mock_repo.add.assert_called_once() + assert result == created_group + + +# Test de création de groupe avec nom vide +@pytest.mark.asyncio +async def test_create_group_empty_name(group_service): + owner_id = uuid4() + + with pytest.raises(ValueError, match="Group name cannot be empty"): + await group_service.create( + owner_id=owner_id, + name="" + ) + + +# Test de suppression de groupe avec succès +@pytest.mark.asyncio +async def test_delete_group_success(group_service, mock_repo): + group_id = uuid4() + group = create_test_group(group_id=group_id) + + mock_repo.find_by_id.return_value = group + + await group_service.delete(group_id) + + mock_repo.find_by_id.assert_called_once_with(group_id) + mock_repo.delete.assert_called_once_with(group_id) + + +# Test de suppression de groupe non existant +@pytest.mark.asyncio +async def test_delete_group_not_found(group_service, mock_repo): + group_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Group with id {group_id} not found"): + await group_service.delete(group_id) + + +# Test de mise à jour de groupe avec succès +@pytest.mark.asyncio +async def test_update_group_success(group_service, mock_repo): + group = create_test_group() + updated_group = create_test_group(group_id=group.id) + updated_group.name = "Updated Name" + + mock_repo.find_by_id.return_value = group + mock_repo.update.return_value = updated_group + + result = await group_service.update(updated_group) + + mock_repo.find_by_id.assert_called_once_with(group.id) + mock_repo.update.assert_called_once_with(updated_group) + assert result == updated_group + + +# Test de mise à jour de groupe non existant +@pytest.mark.asyncio +async def test_update_group_not_found(group_service, mock_repo): + group = create_test_group() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Group with id {group.id} not found"): + await group_service.update(group) + + +# Test d'ajout de membre avec succès +@pytest.mark.asyncio +async def test_add_member_success(group_service, mock_repo): + group_id = uuid4() + user_id = uuid4() + group = create_test_group(group_id=group_id) + user = create_test_profile(profile_id=user_id) + + mock_repo.find_by_id.return_value = group + + # Mock du container et profile_service + with patch('src.container.container') as mock_container: + mock_profile_service = AsyncMock() + mock_profile_service.get_by_id.return_value = user + mock_container.get_profile_service.return_value = mock_profile_service + + await group_service.add_member(group_id, user_id) + + mock_repo.find_by_id.assert_called_once_with(group_id) + mock_profile_service.get_by_id.assert_called_once_with(user_id) + mock_repo.add_member.assert_called_once_with(group_id, user_id) + + +# Test d'ajout de membre - groupe non trouvé +@pytest.mark.asyncio +async def test_add_member_group_not_found(group_service, mock_repo): + group_id = uuid4() + user_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Group {group_id} not found"): + await group_service.add_member(group_id, user_id) + + +# Test d'ajout de membre - utilisateur non trouvé +@pytest.mark.asyncio +async def test_add_member_user_not_found(group_service, mock_repo): + group_id = uuid4() + user_id = uuid4() + group = create_test_group(group_id=group_id) + + mock_repo.find_by_id.return_value = group + + with patch('src.container.container') as mock_container: + mock_profile_service = AsyncMock() + mock_profile_service.get_by_id.side_effect = NotFoundError(f"User {user_id} not found") + mock_container.get_profile_service.return_value = mock_profile_service + + with pytest.raises(NotFoundError, match=f"User {user_id} not found"): + await group_service.add_member(group_id, user_id) + + +# Test de suppression de membre avec succès +@pytest.mark.asyncio +async def test_remove_member_success(group_service, mock_repo): + group_id = uuid4() + user_id = uuid4() + group = create_test_group(group_id=group_id) + member = create_test_profile(profile_id=user_id) + + mock_repo.find_by_id.return_value = group + mock_repo.list_members.return_value = [member] + + await group_service.remove_member(group_id, user_id) + + mock_repo.find_by_id.assert_called_once_with(group_id) + mock_repo.list_members.assert_called_once_with(group_id) + mock_repo.remove_member.assert_called_once_with(group_id, user_id) + + +# Test de suppression de membre - groupe non trouvé +@pytest.mark.asyncio +async def test_remove_member_group_not_found(group_service, mock_repo): + group_id = uuid4() + user_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Group with id {group_id} not found"): + await group_service.remove_member(group_id, user_id) + + +# Test de suppression de membre - membre non trouvé +@pytest.mark.asyncio +async def test_remove_member_not_found(group_service, mock_repo): + group_id = uuid4() + user_id = uuid4() + other_user_id = uuid4() + group = create_test_group(group_id=group_id) + other_member = create_test_profile(profile_id=other_user_id) + + mock_repo.find_by_id.return_value = group + mock_repo.list_members.return_value = [other_member] + + with pytest.raises(NotFoundError, match=f"User with id {user_id} is not a member of group {group_id}"): + await group_service.remove_member(group_id, user_id) + + +# Test de liste des groupes du propriétaire avec succès +@pytest.mark.asyncio +async def test_list_owner_groups_success(group_service, mock_repo): + owner_id = uuid4() + groups = [create_test_group(owner_id=owner_id), create_test_group(owner_id=owner_id)] + + mock_repo.find_by_owner_id.return_value = groups + + result = await group_service.list_owner_groups(owner_id) + + mock_repo.find_by_owner_id.assert_called_once_with(owner_id) + assert result == groups + + +# Test de liste des groupes du propriétaire - aucun groupe trouvé +@pytest.mark.asyncio +async def test_list_owner_groups_not_found(group_service, mock_repo): + owner_id = uuid4() + + mock_repo.find_by_owner_id.return_value = [] + + with pytest.raises(NotFoundError, match=f"No groups found for owner with id {owner_id}"): + await group_service.list_owner_groups(owner_id) + + +# Test de liste des membres avec succès +@pytest.mark.asyncio +async def test_list_members_success(group_service, mock_repo): + group_id = uuid4() + group = create_test_group(group_id=group_id) + members = [create_test_profile(), create_test_profile()] + + mock_repo.find_by_id.return_value = group + mock_repo.list_members.return_value = members + + result = await group_service.list_members(group_id) + + mock_repo.find_by_id.assert_called_once_with(group_id) + mock_repo.list_members.assert_called_once_with(group_id) + assert result == members + + +# Test de liste des membres - groupe non trouvé +@pytest.mark.asyncio +async def test_list_members_group_not_found(group_service, mock_repo): + group_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Group with id {group_id} not found"): + await group_service.list_members(group_id) + + +# Test de liste des membres - aucun membre +@pytest.mark.asyncio +async def test_list_members_empty(group_service, mock_repo): + group_id = uuid4() + group = create_test_group(group_id=group_id) + + mock_repo.find_by_id.return_value = group + mock_repo.list_members.return_value = None + + result = await group_service.list_members(group_id) + + assert result == [] + + +# Test de récupération de tous les groupes avec succès +@pytest.mark.asyncio +async def test_get_all_groups_success(group_service, mock_repo): + groups = [create_test_group(), create_test_group()] + + mock_repo.find_all_groups.return_value = groups + + result = await group_service.get_all_groups() + + mock_repo.find_all_groups.assert_called_once() + assert result == groups + + +# Test de récupération de tous les groupes - aucun groupe trouvé +@pytest.mark.asyncio +async def test_get_all_groups_not_found(group_service, mock_repo): + mock_repo.find_all_groups.return_value = [] + + with pytest.raises(NotFoundError, match="No groups found"): + await group_service.get_all_groups() + + +# Test de récupération de groupe par ID avec succès +@pytest.mark.asyncio +async def test_get_by_id_success(group_service, mock_repo): + group_id = uuid4() + group = create_test_group(group_id=group_id) + + mock_repo.find_by_id.return_value = group + + result = await group_service.get_by_id(group_id) + + mock_repo.find_by_id.assert_called_once_with(group_id) + assert result == group + + +# Test de récupération de groupe par ID - non trouvé +@pytest.mark.asyncio +async def test_get_by_id_not_found(group_service, mock_repo): + group_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Group with id {group_id} not found"): + await group_service.get_by_id(group_id) + + +# Test de récupération des coachs avec succès +@pytest.mark.asyncio +async def test_get_my_coaches_success(group_service, mock_repo): + user_id = uuid4() + coach_id_1 = uuid4() + coach_id_2 = uuid4() + + groups = [ + create_test_group(owner_id=coach_id_1), + create_test_group(owner_id=coach_id_2) + ] + + coach_1 = create_test_profile(profile_id=coach_id_1, roles=["coach"]) + coach_2 = create_test_profile(profile_id=coach_id_2, roles=["coach"]) + + mock_repo.find_groups_by_member_id.return_value = groups + + with patch('src.container.container') as mock_container: + mock_profile_service = AsyncMock() + mock_profile_service.get_by_id.side_effect = [coach_1, coach_2] + mock_container.get_profile_service.return_value = mock_profile_service + + result = await group_service.get_my_coaches(user_id) + + mock_repo.find_groups_by_member_id.assert_called_once_with(user_id) + assert len(result) == 2 + assert coach_1 in result + assert coach_2 in result + + +# Test de récupération des coachs - aucun groupe trouvé +@pytest.mark.asyncio +async def test_get_my_coaches_no_groups(group_service, mock_repo): + user_id = uuid4() + + mock_repo.find_groups_by_member_id.return_value = [] + + with pytest.raises(NotFoundError, match=f"No groups found for user {user_id}"): + await group_service.get_my_coaches(user_id) + + +# Test de récupération des coachs - aucun coach trouvé +@pytest.mark.asyncio +async def test_get_my_coaches_no_coaches(group_service, mock_repo): + user_id = uuid4() + owner_id = uuid4() + + groups = [create_test_group(owner_id=owner_id)] + non_coach = create_test_profile(profile_id=owner_id, roles=["user"]) + + mock_repo.find_groups_by_member_id.return_value = groups + + with patch('src.container.container') as mock_container: + mock_profile_service = AsyncMock() + mock_profile_service.get_by_id.return_value = non_coach + mock_container.get_profile_service.return_value = mock_profile_service + + with pytest.raises(NotFoundError, match="No coaches found in your groups"): + await group_service.get_my_coaches(user_id) + + +# Test de récupération des coachs - coach non trouvé (continue) +@pytest.mark.asyncio +async def test_get_my_coaches_coach_not_found_continues(group_service, mock_repo): + user_id = uuid4() + coach_id_1 = uuid4() + coach_id_2 = uuid4() + + groups = [ + create_test_group(owner_id=coach_id_1), + create_test_group(owner_id=coach_id_2) + ] + + coach_2 = create_test_profile(profile_id=coach_id_2, roles=["coach"]) + + mock_repo.find_groups_by_member_id.return_value = groups + + with patch('src.container.container') as mock_container: + mock_profile_service = AsyncMock() + # Premier coach non trouvé, deuxième trouvé + mock_profile_service.get_by_id.side_effect = [ + NotFoundError("Coach not found"), + coach_2 + ] + mock_container.get_profile_service.return_value = mock_profile_service + + result = await group_service.get_my_coaches(user_id) + + assert len(result) == 1 + assert coach_2 in result \ No newline at end of file diff --git a/src/domain/tests/test_profile_service.py b/src/domain/tests/test_profile_service.py new file mode 100644 index 0000000..4ffc03c --- /dev/null +++ b/src/domain/tests/test_profile_service.py @@ -0,0 +1,237 @@ +import pytest +from uuid import uuid4 +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) + +from src.domain.services.profile import ProfileService +from src.domain.ports.profile_repository import ProfileRepository +from src.domain.ports.password_hasher import PasswordHasher +from src.domain.exceptions import DuplicateProfileError, AuthenticationError, NotFoundError, InvalidConfirmPasswordError, InvalidFormatEmailError +from unittest.mock import AsyncMock + + +# Fixture pour simuler le repository (ProfileRepository) +@pytest.fixture +def mock_repo(): + repo = AsyncMock(spec=ProfileRepository) + return repo + + +# Fixture pour simuler le PasswordHasher +@pytest.fixture +def mock_hasher(): + hasher = AsyncMock(spec=PasswordHasher) + return hasher + + +# Fixture pour créer une instance de ProfileService avec les mocks +@pytest.fixture +def profile_service(mock_repo, mock_hasher): + return ProfileService(repo=mock_repo, hasher=mock_hasher) + + +# Test de la création de profil avec email déjà existant +@pytest.mark.asyncio +async def test_create_profile_duplicate_email(profile_service, mock_repo): + email = "test@example.com" + password = "password123" + confirm_password = "password123" + + # Simuler qu'un profil existe déjà avec l'email + mock_repo.find_by_email.return_value = object() + with pytest.raises(DuplicateProfileError): + await profile_service.create( + email=email, + raw_password=password, + confirm_password=confirm_password + ) + + +# Test de la création de profil avec un format d'email invalide +@pytest.mark.asyncio +async def test_create_profile_invalid_email(profile_service): + email = "invalidemail" + password = "password123" + confirm_password = "password123" + + with pytest.raises(InvalidFormatEmailError): + await profile_service.create( + email=email, + raw_password=password, + confirm_password=confirm_password + ) + + +# Test de la création de profil avec des mots de passe non correspondants +@pytest.mark.asyncio +async def test_create_profile_password_mismatch(profile_service, mock_repo): + email = "test@example.com" + password = "password123" + confirm_password = "differentpassword" + + # Simuler qu'aucun profil n'existe avec cet email (None au lieu d'un objet) + mock_repo.find_by_email.return_value = None + + with pytest.raises(InvalidConfirmPasswordError): + await profile_service.create( + email=email, + raw_password=password, + confirm_password=confirm_password + ) + + +# Test de la suppression de profil avec ID existant +@pytest.mark.asyncio +async def test_delete_profile_success(profile_service, mock_repo): + profile_id = uuid4() + + # Simuler qu'un profil existe + profile = AsyncMock() + profile.id = profile_id + mock_repo.find_by_id.return_value = profile + await profile_service.delete(profile_id) + + mock_repo.find_by_id.assert_called_once_with(profile_id) + mock_repo.delete.assert_called_once_with(profile_id) + + +# Test de la suppression de profil avec ID non existant +@pytest.mark.asyncio +async def test_delete_profile_not_found(profile_service, mock_repo): + profile_id = uuid4() + + # Simuler qu'aucun profil n'est trouvé + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError): + await profile_service.delete(profile_id) + + +# Test de login avec succès +@pytest.mark.asyncio +async def test_login_success(profile_service, mock_repo, mock_hasher): + email = "test@example.com" + password = "password123" + + profile = AsyncMock() + profile.email = email + profile.password = "hashedpassword" + mock_repo.find_by_email.return_value = profile + mock_hasher.verify.return_value = True + + logged_in_profile = await profile_service.login(email=email, password=password) + + mock_repo.find_by_email.assert_called_once_with(email) + mock_hasher.verify.assert_called_once_with(password, profile.password) + + assert logged_in_profile == profile + + +# Test de login avec mot de passe incorrect +@pytest.mark.asyncio +async def test_login_invalid_password(profile_service, mock_repo, mock_hasher): + email = "test@example.com" + password = "wrongpassword" + + profile = AsyncMock() + profile.email = email + profile.password = "hashedpassword" + mock_repo.find_by_email.return_value = profile + mock_hasher.verify.return_value = False + + with pytest.raises(AuthenticationError): + await profile_service.login(email=email, password=password) + + +# Test de la mise à jour du profil +@pytest.mark.asyncio +async def test_update_profile(profile_service, mock_repo): + profile_id = uuid4() + updated_name = "New Name" + + # Simuler un profil existant + profile = AsyncMock() + profile.id = profile_id + profile.name = "Old Name" + mock_repo.find_by_id.return_value = profile + # Le mock doit retourner le profil modifié, pas un nouveau mock + mock_repo.update.return_value = profile + + updated_profile = await profile_service.update( + id=profile_id, name=updated_name + ) + + mock_repo.find_by_id.assert_called_once_with(profile_id) + mock_repo.update.assert_called_once_with(profile) + # Vérifier que le nom a été mis à jour sur le profil original + assert profile.name == updated_name + assert updated_profile == profile + + +# Test de mise à jour de l'email +@pytest.mark.asyncio +async def test_update_email(profile_service, mock_repo): + profile_id = uuid4() + new_email = "newemail@example.com" + + # Simuler un profil existant avec un email + profile = AsyncMock() + profile.email = "oldemail@example.com" + mock_repo.find_by_email.return_value = None # Simuler qu'aucun autre profil n'a cet email + mock_repo.find_by_id.return_value = profile + # Le mock doit retourner le profil modifié + mock_repo.update.return_value = profile + + updated_profile = await profile_service.update_email(profile_id, new_email) + + mock_repo.find_by_id.assert_called_once_with(profile_id) + mock_repo.find_by_email.assert_called_once_with(new_email) + mock_repo.update.assert_called_once_with(profile) + # Vérifier que l'email a été mis à jour sur le profil original + assert profile.email == new_email + assert updated_profile == profile + + +# Test de mise à jour du mot de passe +@pytest.mark.asyncio +async def test_update_password(profile_service, mock_repo, mock_hasher): + profile_id = uuid4() + old_password = "oldpassword" + new_password = "newpassword" + + # Simuler un profil avec un mot de passe + profile = AsyncMock() + profile.password = "hashedoldpassword" + mock_repo.find_by_id.return_value = profile + mock_hasher.verify.return_value = True + mock_hasher.hash.return_value = "hashednewpassword" + + await profile_service.update_password(profile_id, old_password, new_password) + + # Corriger l'assertion : vérifier avec l'ancien mot de passe hashé + mock_hasher.verify.assert_called_once_with(old_password, "hashedoldpassword") + mock_hasher.hash.assert_called_once_with(new_password) + mock_repo.update.assert_called_once_with(profile) + + +# Test de mise à jour des rôles +@pytest.mark.asyncio +async def test_update_roles(profile_service, mock_repo): + profile_id = uuid4() + new_roles = ["admin"] + + # Simuler un profil avec des rôles existants + profile = AsyncMock() + profile.roles = ["user"] + mock_repo.find_by_id.return_value = profile + # Le mock doit retourner le profil modifié + mock_repo.update.return_value = profile + + updated_profile = await profile_service.update_roles(profile_id, new_roles) + + mock_repo.find_by_id.assert_called_once_with(profile_id) + mock_repo.update.assert_called_once_with(profile) + # Vérifier que les rôles ont été mis à jour sur le profil original + assert profile.roles == new_roles + assert updated_profile == profile \ No newline at end of file diff --git a/src/domain/tests/test_training_service.py b/src/domain/tests/test_training_service.py new file mode 100644 index 0000000..9ce5668 --- /dev/null +++ b/src/domain/tests/test_training_service.py @@ -0,0 +1,457 @@ +import pytest +from uuid import uuid4, UUID +from datetime import datetime +from unittest.mock import AsyncMock +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) + +from src.domain.services.training import TrainingService +from src.domain.ports.training_repository import TrainingRepository +from src.domain.model.training import Training as DomainTraining, Task as DomainTask, Validate as DomainValidate +from src.domain.exceptions import NotFoundError + + +# Fixture pour simuler le repository (TrainingRepository) +@pytest.fixture +def mock_repo(): + repo = AsyncMock(spec=TrainingRepository) + return repo + + +# Fixture pour créer une instance de TrainingService avec le mock +@pytest.fixture +def training_service(mock_repo): + return TrainingService(repo=mock_repo) + + +# Helper functions pour créer des objets de test +def create_test_training(training_id=None, owner_id=None, name="Test Training"): + return DomainTraining( + id=training_id or uuid4(), + owner_id=owner_id or uuid4(), + name=name, + description="Test description", + created_at=datetime.utcnow() + ) + + +def create_test_task(task_id=None, training_id=None, exercise_name="Test Exercise"): + return DomainTask( + id=task_id or uuid4(), + training_id=training_id or uuid4(), + exercise_name=exercise_name, + rest_time=60, + repetitions=10, + set_number=3, + method="standard", + rir=2, + updated_at=datetime.utcnow(), + validate=[] + ) + + +def create_test_validate(validate_id=None, task_id=None, exercise_name="Test Exercise"): + return DomainValidate( + id=validate_id or uuid4(), + task_id=task_id or uuid4(), + exercise_name=exercise_name, + rest_time=60, + repetitions=10, + set_number=3, + rir=2, + updated_at=datetime.utcnow(), + succeeded_at=datetime.utcnow() + ) + + +# Tests pour Training +@pytest.mark.asyncio +async def test_get_training_success(training_service, mock_repo): + training_id = uuid4() + training = create_test_training(training_id=training_id) + + mock_repo.find_by_id.return_value = training + + result = await training_service.get_training(training_id) + + mock_repo.find_by_id.assert_called_once_with(training_id) + assert result == training + + +@pytest.mark.asyncio +async def test_get_training_not_found(training_service, mock_repo): + training_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Training {training_id} not found"): + await training_service.get_training(training_id) + + +@pytest.mark.asyncio +async def test_create_training_success(training_service, mock_repo): + owner_id = uuid4() + name = "New Training" + description = "Training description" + + created_training = create_test_training(owner_id=owner_id, name=name) + mock_repo.add_training.return_value = created_training + + result = await training_service.create_training(owner_id, name, description) + + mock_repo.add_training.assert_called_once() + assert result == created_training + + +@pytest.mark.asyncio +async def test_delete_training_success(training_service, mock_repo): + training_id = uuid4() + training = create_test_training(training_id=training_id) + + mock_repo.find_by_id.return_value = training + + await training_service.delete_training(training_id) + + mock_repo.find_by_id.assert_called_once_with(training_id) + mock_repo.delete_training.assert_called_once_with(training_id) + + +@pytest.mark.asyncio +async def test_delete_training_not_found(training_service, mock_repo): + training_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Training {training_id} not found"): + await training_service.delete_training(training_id) + + +@pytest.mark.asyncio +async def test_update_training_success(training_service, mock_repo): + training_id = uuid4() + training = create_test_training(training_id=training_id) + updated_training = create_test_training(training_id=training_id) + updated_training.name = "Updated Training" + updated_training.description = "Updated description" + + mock_repo.find_by_id.return_value = training + mock_repo.update_training.return_value = updated_training + + result = await training_service.update_training( + training_id=training_id, + name="Updated Training", + description="Updated description" + ) + + mock_repo.find_by_id.assert_called_once_with(training_id) + assert training.name == "Updated Training" + assert training.description == "Updated description" + mock_repo.update_training.assert_called_once_with(training) + assert result == updated_training + + +@pytest.mark.asyncio +async def test_update_training_not_found(training_service, mock_repo): + training_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Training {training_id} not found"): + await training_service.update_training(training_id, name="New Name") + + +@pytest.mark.asyncio +async def test_get_all_owner_trainings_success(training_service, mock_repo): + owner_id = uuid4() + trainings = [ + create_test_training(owner_id=owner_id), + create_test_training(owner_id=owner_id) + ] + + mock_repo.find_all_owner_trainings.return_value = trainings + + result = await training_service.get_all_owner_trainings(owner_id) + + mock_repo.find_all_owner_trainings.assert_called_once_with(owner_id) + assert result == trainings + + +@pytest.mark.asyncio +async def test_get_all_owner_trainings_empty(training_service, mock_repo): + owner_id = uuid4() + + mock_repo.find_all_owner_trainings.return_value = [] + + result = await training_service.get_all_owner_trainings(owner_id) + + assert result == [] + + +# Tests pour Task +@pytest.mark.asyncio +async def test_create_task_success(training_service, mock_repo): + training_id = uuid4() + training = create_test_training(training_id=training_id) + exercise_name = "Push-ups" + + created_task = create_test_task(training_id=training_id, exercise_name=exercise_name) + mock_repo.find_by_id.return_value = training + mock_repo.add_task.return_value = created_task + + result = await training_service.create_task( + training_id=training_id, + exercise_name=exercise_name, + rest_time=60, + repetitions=10, + set_number=3, + method="standard", + rir=2 + ) + + mock_repo.find_by_id.assert_called_once_with(training_id) + mock_repo.add_task.assert_called_once() + assert result == created_task + + +@pytest.mark.asyncio +async def test_create_task_training_not_found(training_service, mock_repo): + training_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Training {training_id} not found"): + await training_service.create_task( + training_id=training_id, + exercise_name="Push-ups" + ) + + +@pytest.mark.asyncio +async def test_create_task_empty_exercise_name(training_service, mock_repo): + training_id = uuid4() + training = create_test_training(training_id=training_id) + + mock_repo.find_by_id.return_value = training + + with pytest.raises(ValueError, match="Exercise name is required"): + await training_service.create_task( + training_id=training_id, + exercise_name="" + ) + + +@pytest.mark.asyncio +async def test_get_task_success(training_service, mock_repo): + task_id = uuid4() + task = create_test_task(task_id=task_id) + + mock_repo.find_task_by_id.return_value = task + + result = await training_service.get_task(task_id) + + mock_repo.find_task_by_id.assert_called_once_with(task_id) + assert result == task + + +@pytest.mark.asyncio +async def test_get_task_not_found(training_service, mock_repo): + task_id = uuid4() + + mock_repo.find_task_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Task {task_id} not found"): + await training_service.get_task(task_id) + + +@pytest.mark.asyncio +async def test_update_task_success(training_service, mock_repo): + task_id = uuid4() + task = create_test_task(task_id=task_id) + updated_task = create_test_task(task_id=task_id) + + mock_repo.find_task_by_id.return_value = task + mock_repo.update_task.return_value = updated_task + + result = await training_service.update_task( + task_id=task_id, + exercise_name="Updated Exercise", + rest_time=90, + repetitions=15 + ) + + mock_repo.find_task_by_id.assert_called_once_with(task_id) + assert task.exercise_name == "Updated Exercise" + assert task.rest_time == 90 + assert task.repetitions == 15 + mock_repo.update_task.assert_called_once_with(task) + assert result == updated_task + + +@pytest.mark.asyncio +async def test_update_task_not_found(training_service, mock_repo): + task_id = uuid4() + + mock_repo.find_task_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Task {task_id} not found"): + await training_service.update_task(task_id, exercise_name="New Exercise") + + +@pytest.mark.asyncio +async def test_delete_task_success(training_service, mock_repo): + task_id = uuid4() + task = create_test_task(task_id=task_id) + + mock_repo.find_task_by_id.return_value = task + + await training_service.delete_task(task_id) + + mock_repo.find_task_by_id.assert_called_once_with(task_id) + mock_repo.delete_task.assert_called_once_with(task_id) + + +@pytest.mark.asyncio +async def test_delete_task_not_found(training_service, mock_repo): + task_id = uuid4() + + mock_repo.find_task_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Task {task_id} not found"): + await training_service.delete_task(task_id) + + +@pytest.mark.asyncio +async def test_list_tasks_for_training_success(training_service, mock_repo): + training_id = uuid4() + tasks = [ + create_test_task(training_id=training_id), + create_test_task(training_id=training_id) + ] + + mock_repo.find_tasks_by_training_id.return_value = tasks + + result = await training_service.list_tasks_for_training(training_id) + + mock_repo.find_tasks_by_training_id.assert_called_once_with(training_id) + assert result == tasks + + +@pytest.mark.asyncio +async def test_list_tasks_for_training_empty(training_service, mock_repo): + training_id = uuid4() + + mock_repo.find_tasks_by_training_id.return_value = None + + result = await training_service.list_tasks_for_training(training_id) + + assert result == [] + + +# Tests pour Validate +@pytest.mark.asyncio +async def test_create_validate_success(training_service, mock_repo): + task_id = uuid4() + task = create_test_task(task_id=task_id) + + created_validate = create_test_validate(task_id=task_id, exercise_name=task.exercise_name) + mock_repo.find_task_by_id.return_value = task + mock_repo.add_validate.return_value = created_validate + + result = await training_service.create_validate( + task_id=task_id, + rest_time=60, + repetitions=10, + set_number=3, + rir=2 + ) + + mock_repo.find_task_by_id.assert_called_once_with(task_id) + mock_repo.add_validate.assert_called_once() + assert result == created_validate + + +@pytest.mark.asyncio +async def test_create_validate_task_not_found(training_service, mock_repo): + task_id = uuid4() + + mock_repo.find_task_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Task {task_id} not found"): + await training_service.create_validate(task_id=task_id) + + +@pytest.mark.asyncio +async def test_get_validates_for_task_success(training_service, mock_repo): + task_id = uuid4() + validates = [ + create_test_validate(task_id=task_id), + create_test_validate(task_id=task_id) + ] + + mock_repo.find_validate_by_task_id.return_value = validates + + result = await training_service.get_validates_for_task(task_id) + + mock_repo.find_validate_by_task_id.assert_called_once_with(task_id) + assert result == validates + + +@pytest.mark.asyncio +async def test_get_validates_for_task_empty(training_service, mock_repo): + task_id = uuid4() + + mock_repo.find_validate_by_task_id.return_value = [] + + result = await training_service.get_validates_for_task(task_id) + + assert result == [] + + +@pytest.mark.asyncio +async def test_delete_validate_success(training_service, mock_repo): + validate_id = uuid4() + validate = create_test_validate(validate_id=validate_id) + + mock_repo.find_validate_by_id.return_value = validate + + await training_service.delete_validate(validate_id) + + mock_repo.find_validate_by_id.assert_called_once_with(validate_id) + mock_repo.delete_validate.assert_called_once_with(validate_id) + + +@pytest.mark.asyncio +async def test_delete_validate_not_found(training_service, mock_repo): + validate_id = uuid4() + + mock_repo.find_validate_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Validation {validate_id} not found"): + await training_service.delete_validate(validate_id) + + +@pytest.mark.asyncio +async def test_get_validate_by_training_id_success(training_service, mock_repo): + training_id = uuid4() + training = create_test_training(training_id=training_id) + validates = [create_test_validate(), create_test_validate()] + + mock_repo.find_by_id.return_value = training + mock_repo.find_all_validates_by_training_id.return_value = validates + + result = await training_service.get_validate_by_training_id(training_id) + + mock_repo.find_by_id.assert_called_once_with(training_id) + mock_repo.find_all_validates_by_training_id.assert_called_once_with(training_id) + assert result == validates + + +@pytest.mark.asyncio +async def test_get_validate_by_training_id_training_not_found(training_service, mock_repo): + training_id = uuid4() + + mock_repo.find_by_id.return_value = None + + with pytest.raises(NotFoundError, match=f"Training {training_id} not found"): + await training_service.get_validate_by_training_id(training_id) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 6fde05f..99f47d2 100644 --- a/uv.lock +++ b/uv.lock @@ -610,6 +610,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -739,7 +751,7 @@ wheels = [ [[package]] name = "tracknatrainapi" -version = "0.5.7" +version = "0.5.9" source = { editable = "." } dependencies = [ { name = "annotated-types" }, @@ -771,6 +783,7 @@ dependencies = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-mock" }, { name = "python-dateutil" }, { name = "python-dotenv" }, { name = "python-jose" }, @@ -832,6 +845,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'testing'", specifier = ">=0.20" }, { name = "pytest-cov", specifier = ">=4.0" }, { name = "pytest-cov", marker = "extra == 'testing'", specifier = ">=4.0" }, + { name = "pytest-mock", specifier = ">=3.10,<4.0" }, { name = "python-dateutil", specifier = "==2.9.0.post0" }, { name = "python-dotenv", specifier = "==1.1.0" }, { name = "python-jose", specifier = "==3.4.0" },