First commit

This commit is contained in:
Johan
2025-12-17 13:29:14 +01:00
commit cc50161771
43 changed files with 3665 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
"""
Tests de validation pour l'Étape 2 : Couche d'accès aux données (Repositories)
[Version Corrigée]
Cette version corrige les assertions pour accepter les arguments positionnels (args)
au lieu de forcer les arguments mots-clés (kwargs) lors de l'appel à _request.
Elle corrige également la simulation de DALException pour qu'elle corresponde
à la logique de _base_client.py.
"""
import pytest
import pytest_asyncio
from unittest.mock import MagicMock, AsyncMock
from app.core.exceptions import DALException
from app.models.movie import Movie
from app.models.genre import Genre
# --- Fixtures de données (simule les réponses de l'API) ---
@pytest.fixture
def mock_movie_data():
"""Données JSON brutes pour un film, simulées depuis l'API."""
return {
"id": 1,
"title": "Inception",
"year": 2010,
"duration": 148,
"synopsis": "Un voleur...",
"genre": {"id": 1, "label": "Science-Fiction"},
"director": {"id": 1, "last_name": "Nolan", "first_name": "Christopher"},
"actors": [{"id": 2, "last_name": "DiCaprio", "first_name": "Leonardo"}],
"opinions": []
}
@pytest.fixture
def mock_genre_list_data():
"""Données JSON brutes pour une liste de genres."""
return [
{"id": 1, "label": "Science-Fiction"},
{"id": 2, "label": "Drame"}
]
# --- Tests ---
@pytest.mark.asyncio
async def test_movie_repo_find_by_id_success(mocker, mock_movie_data):
"""
Vérifie que movie_repository.find_by_id retourne un objet Movie
en cas de succès.
"""
# 1. Mock de l'api_client
mock_response = MagicMock()
mock_response.json.return_value = mock_movie_data
mock_api_client = AsyncMock()
mock_api_client._request.return_value = mock_response
# On "patch" l'instance importée dans le module du repository
mocker.patch('app.repositories.movie_repository.api_client', mock_api_client)
# 2. Appel de la méthode à tester
from app.repositories.movie_repository import movie_repository
movie = await movie_repository.find_by_id(1)
# 3. Assertions
mock_api_client._request.assert_called_once_with("GET", "/movies/1")
assert movie is not None
assert isinstance(movie, Movie)
assert movie.title == "Inception"
@pytest.mark.asyncio
async def test_movie_repo_find_by_id_not_found(mocker):
"""
Vérifie que movie_repository.find_by_id retourne None si l'API
lève une DALException avec un status 404.
"""
# 1. Mock de l'api_client pour qu'il lève une erreur 404
mock_api_client = AsyncMock()
# 1. Crée l'exception
mock_exception = DALException("Not Found")
# 2. Attache le status_code
mock_exception.status_code = 404
# 3. La définit comme side_effect
mock_api_client._request.side_effect = mock_exception
mocker.patch('app.repositories.movie_repository.api_client', mock_api_client)
# 2. Appel de la méthode à tester
from app.repositories.movie_repository import movie_repository
movie = await movie_repository.find_by_id(999)
# 3. Assertions
mock_api_client._request.assert_called_once_with("GET", "/movies/999")
assert movie is None
@pytest.mark.asyncio
async def test_movie_repo_list(mocker, mock_movie_data):
"""
Vérifie que movie_repository.list retourne une liste de Movies
et passe correctement les paramètres skip/limit.
"""
# 1. Mock de l'api_client
mock_response = MagicMock()
mock_response.json.return_value = [mock_movie_data] # L'API retourne une liste
mock_api_client = AsyncMock()
mock_api_client._request.return_value = mock_response
mocker.patch('app.repositories.movie_repository.api_client', mock_api_client)
# 2. Appel de la méthode à tester
from app.repositories.movie_repository import movie_repository
movies = await movie_repository.list(skip=5, limit=10)
# 3. Assertions
expected_params = {"skip": 5, "limit": 10}
mock_api_client._request.assert_called_once_with("GET", "/movies/", params=expected_params)
assert isinstance(movies, list)
assert isinstance(movies[0], Movie)
assert movies[0].id == 1
@pytest.mark.asyncio
async def test_genre_repo_list(mocker, mock_genre_list_data):
"""
Vérifie que genre_repository.list retourne une liste de Genres.
"""
# 1. Mock de l'api_client
mock_response = MagicMock()
mock_response.json.return_value = mock_genre_list_data
mock_api_client = AsyncMock()
mock_api_client._request.return_value = mock_response
mocker.patch('app.repositories.genre_repository.api_client', mock_api_client)
# 2. Appel de la méthode à tester
from app.repositories.genre_repository import genre_repository
genres = await genre_repository.list()
# 3. Assertions
mock_api_client._request.assert_called_once_with("GET", "/genres/")
assert isinstance(genres, list)
assert isinstance(genres[0], Genre)
assert genres[0].label == "Science-Fiction"

View File

@@ -0,0 +1,70 @@
"""
Tests de validation pour l'Étape 3 : Mise en place GraphQL (Version 1 - Statique)
Objectif :
1. Vérifier que le service `movie_analyzer_v1` retourne bien un dict "mock".
2. Vérifier que le resolver `analyze_movie_v1` appelle ce service et
construit correctement l'objet `MovieAnalysis`.
Prérequis :
- Les TODOs de `app/services/movie_analyzer_v1.py` sont complétés.
- Les TODOs de `app/graphql/resolvers/analyze_movie_v1.py` sont complétés.
- Le champ `analyzeMovie` est bien ajouté à `app/graphql/queries.py`
(en utilisant le resolver V1).
"""
import pytest
import strawberry
from unittest.mock import AsyncMock
from app.graphql.types.movie_analysis import MovieAnalysis
@pytest.mark.asyncio
async def test_service_v1_analyze_movie():
"""
Teste le service V1. Il doit retourner un dictionnaire
contenant les clés requises et l'ID correct.
"""
from app.services.movie_analyzer_v1 import analyze_movie
movie_id = "123"
analysis_data = await analyze_movie(movie_id)
assert isinstance(analysis_data, dict)
assert analysis_data["id"] == movie_id
assert "aiSummary" in analysis_data
assert "aiOpinionSummary" in analysis_data
assert "aiBestGenre" in analysis_data
assert "aiTags" in analysis_data
assert isinstance(analysis_data["aiTags"], list)
@pytest.mark.asyncio
async def test_resolver_v1_analyze_movie_by_id(mocker):
"""
Teste le resolver V1. Il doit appeler le service V1 et
retourner un objet `MovieAnalysis` typé.
"""
# 1. Mock du service V1 que le resolver est censé appeler
mock_service_data = {
"id": "1",
"aiSummary": "Mock summary",
"aiOpinionSummary": "Mock opinion",
"aiBestGenre": "Mock genre",
"aiTags": ["mock", "test"]
}
mock_analyze_service = AsyncMock(return_value=mock_service_data)
mocker.patch('app.graphql.resolvers.analyze_movie_v1.analyze_movie', mock_analyze_service)
# 2. Appel du resolver
from app.graphql.resolvers.analyze_movie_v1 import analyze_movie_by_id
result = await analyze_movie_by_id(movie_id=strawberry.ID("1"))
# 3. Assertions
# Vérifie que le service a bien été appelé
mock_analyze_service.assert_called_once_with(movie_id="1")
# Vérifie que le résultat est du bon type
assert isinstance(result, MovieAnalysis)
assert result.id == strawberry.ID("1")
assert result.aiSummary == "Mock summary"
assert result.aiTags == ["mock", "test"]

188
tests/etape5_service_v2.py Normal file
View File

@@ -0,0 +1,188 @@
"""
Tests de validation pour l'Étape 5 : Service d'analyse avancé (Version 2 - Dynamique)
Objectif :
1. Vérifier que les fonctions `get_ai_...` appellent bien le LLM.
2. Vérifier que la fonction principale `analyze_movie` (V2)
n'appelle QUE les fonctions nécessaires (en fonction des booléens).
3. Vérifier que les appels aux repositories sont corrects.
Prérequis :
- Les TODOs de `app/services/movie_analyzer_v2.py` sont complétés.
"""
import pytest
import strawberry
from unittest.mock import AsyncMock, MagicMock, patch
from app.models.movie import Movie
from app.models.genre import Genre
from app.models.person import Person
from app.models.opinion import Opinion
from app.models.member import Member
# --- Fixtures ---
@pytest.fixture
def mock_llm():
"""Fixture pour un LLM mocké."""
llm = MagicMock()
# Simule la réponse de llm.ainvoke(...)
mock_response = MagicMock()
mock_response.content = "Réponse du LLM"
llm.ainvoke = AsyncMock(return_value=mock_response)
return llm
@pytest.fixture
def mock_movie():
"""Fixture pour un objet Movie Pydantic complet."""
return Movie(
id=1,
title="Inception",
year=2010,
synopsis="Un voleur qui vole des secrets...",
genre=Genre(id=1, label="Science-Fiction"),
director=Person(id=1, last_name="Nolan", first_name="Christopher"),
actors=[Person(id=2, last_name="DiCaprio", first_name="Leonardo")],
opinions=[
Opinion(id=1, note=5, comment="Génial!", movie_id=1, member=Member(id=1, login="user1"))
]
)
@pytest.fixture
def mock_genres():
"""Fixture pour une liste d'objets Genre Pydantic."""
return [
Genre(id=1, label="Science-Fiction"),
Genre(id=2, label="Drame")
]
# --- Tests des helpers LLM ---
@pytest.mark.asyncio
async def test_get_ai_summary(mock_llm):
"""Teste le prompt de résumé."""
from app.services.movie_analyzer_v2 import get_ai_summary
synopsis = "Un long synopsis..."
result = await get_ai_summary(mock_llm, synopsis)
assert result == "Réponse du LLM"
mock_llm.ainvoke.assert_called_once()
prompt_call = mock_llm.ainvoke.call_args[0][0]
assert synopsis in prompt_call # Vérifie que le synopsis est dans le prompt
@pytest.mark.asyncio
async def test_get_ai_summary_no_synopsis(mock_llm):
"""Teste que le LLM n'est pas appelé si le synopsis est vide."""
from app.services.movie_analyzer_v2 import get_ai_summary
result = await get_ai_summary(mock_llm, None)
assert result is None
mock_llm.ainvoke.assert_not_called()
# ... (Des tests similaires pourraient être écrits pour get_ai_opinion_summary,
# get_ai_best_genre, et get_ai_tags)
# --- Test du service principal (V2) ---
@pytest.mark.asyncio
async def test_service_v2_analyze_movie_partial_request(mocker, mock_llm, mock_movie):
"""
Teste analyze_movie V2 avec une demande partielle (juste aiSummary).
Vérifie que SEULS les appels nécessaires sont faits.
"""
# 1. Mocker les dépendances (repositories)
mock_movie_repo = AsyncMock()
mock_movie_repo.find_by_id.return_value = mock_movie
mocker.patch('app.services.movie_analyzer_v2.movie_repository', mock_movie_repo)
mock_genre_repo = AsyncMock()
mocker.patch('app.services.movie_analyzer_v2.genre_repository', mock_genre_repo)
# 2. Mocker les helpers LLM (pour les espionner)
mock_summary = AsyncMock(return_value="Résumé IA")
mock_opinion = AsyncMock()
mock_genre = AsyncMock()
mock_tags = AsyncMock()
mocker.patch('app.services.movie_analyzer_v2.get_ai_summary', mock_summary)
mocker.patch('app.services.movie_analyzer_v2.get_ai_opinion_summary', mock_opinion)
mocker.patch('app.services.movie_analyzer_v2.get_ai_best_genre', mock_genre)
mocker.patch('app.services.movie_analyzer_v2.get_ai_tags', mock_tags)
# 3. Appel du service
from app.services.movie_analyzer_v2 import analyze_movie
result = await analyze_movie(
movie_id="1",
ai_summary=True,
ai_opinion_summary=False,
ai_best_genre=False,
ai_tags=False,
llm=mock_llm
)
# 4. Assertions
# Le repo de film a été appelé
mock_movie_repo.find_by_id.assert_called_once_with("1")
# Le repo de genre NE DOIT PAS être appelé
mock_genre_repo.list.assert_not_called()
# Seul le helper de résumé a été appelé
mock_summary.assert_called_once()
mock_opinion.assert_not_called()
mock_genre.assert_not_called()
mock_tags.assert_not_called()
# Le résultat est correct
assert result['id'] == strawberry.ID("1")
assert result['aiSummary'] == "Résumé IA"
assert result['aiOpinionSummary'] is None
@pytest.mark.asyncio
async def test_service_v2_analyze_movie_full_request(mocker, mock_llm, mock_movie, mock_genres):
"""
Teste analyze_movie V2 avec une demande complète.
Vérifie que tout est appelé (y compris asyncio.gather).
"""
# 1. Mocker les dépendances (repositories)
mock_movie_repo = AsyncMock()
mock_movie_repo.find_by_id.return_value = mock_movie
mocker.patch('app.services.movie_analyzer_v2.movie_repository', mock_movie_repo)
mock_genre_repo = AsyncMock()
mock_genre_repo.list.return_value = mock_genres
mocker.patch('app.services.movie_analyzer_v2.genre_repository', mock_genre_repo)
# 2. Mocker les helpers LLM
mocker.patch('app.services.movie_analyzer_v2.get_ai_summary', AsyncMock(return_value="Résumé IA"))
mocker.patch('app.services.movie_analyzer_v2.get_ai_opinion_summary', AsyncMock(return_value="Opinion IA"))
mocker.patch('app.services.movie_analyzer_v2.get_ai_best_genre', AsyncMock(return_value="Genre IA"))
mocker.patch('app.services.movie_analyzer_v2.get_ai_tags', AsyncMock(return_value=["tag1", "tag2"]))
# 3. Appel du service
from app.services.movie_analyzer_v2 import analyze_movie
result = await analyze_movie(
movie_id="1",
ai_summary=True,
ai_opinion_summary=True,
ai_best_genre=True,
ai_tags=True,
llm=mock_llm
)
# 4. Assertions
# Les deux repos ont été appelés
mock_movie_repo.find_by_id.assert_called_once_with("1")
mock_genre_repo.list.assert_called_once() # Appelé car ai_best_genre=True
# Le résultat est complet
assert result['aiSummary'] == "Résumé IA"
assert result['aiOpinionSummary'] == "Opinion IA"
assert result['aiBestGenre'] == "Genre IA"
assert result['aiTags'] == ["tag1", "tag2"]

110
tests/etape6_resolver_v2.py Normal file
View File

@@ -0,0 +1,110 @@
"""
Tests de validation pour l'Étape 6 : Optimisation du Resolver GraphQL (Version 2)
Objectif :
1. Vérifier que le resolver V2 récupère bien le LLM du contexte.
2. Vérifier que le resolver V2 utilise `is_field_requested` pour
passer les bons booléens au service V2.
Prérequis :
- Les TODOs de `app/graphql/resolvers/analyze_movie_v2.py` sont complétés.
- Le champ `analyzeMovie` de `app/graphql/queries.py` est mis à jour
pour utiliser le resolver V2.
"""
import pytest
import strawberry
from unittest.mock import AsyncMock, MagicMock, patch
@pytest.fixture
def mock_info():
"""Fixture pour un objet Info de Strawberry."""
mock_llm_instance = MagicMock(name="MockLLM")
info = MagicMock()
info.context = {"llm": mock_llm_instance}
return info
@pytest.mark.asyncio
async def test_resolver_v2_partial_request(mocker, mock_info):
"""
Teste le resolver V2 avec une requête partielle.
Vérifie qu'il passe les bons drapeaux au service.
"""
# 1. Mocker les dépendances (le service V2 et le helper is_field_requested)
# On mock le service V2 pour espionner ses arguments
mock_service = AsyncMock(return_value={
"id": "1",
"aiSummary": "Service Result",
"aiOpinionSummary": None,
"aiBestGenre": None,
"aiTags": None
})
mocker.patch('app.graphql.resolvers.analyze_movie_v2.analyze_movie', mock_service)
# On mock 'is_field_requested' pour simuler une requête partielle
def mock_is_field_requested(info, field_name):
if field_name == "aiSummary":
return True
return False
mocker.patch('app.graphql.resolvers.analyze_movie_v2.is_field_requested', mock_is_field_requested)
# 2. Appel du resolver
from app.graphql.resolvers.analyze_movie_v2 import analyze_movie_by_id
await analyze_movie_by_id(
movie_id=strawberry.ID("1"),
info=mock_info
)
# 3. Assertions
# Vérifie que le service a été appelé avec les bons drapeaux
mock_service.assert_called_once_with(
movie_id="1",
ai_summary=True,
ai_opinion_summary=False,
ai_best_genre=False,
ai_tags=False,
llm=mock_info.context["llm"] # Vérifie que le LLM du contexte est bien passé
)
@pytest.mark.asyncio
async def test_resolver_v2_full_request(mocker, mock_info):
"""
Teste le resolver V2 avec une requête complète.
"""
# 1. Mocker les dépendances
# [CORRECTION] Le mock doit retourner un dictionnaire complet
# pour que MovieAnalysis(**analysis_data) fonctionne.
mock_service_return = {
"id": "1",
"aiSummary": "Mock summary",
"aiOpinionSummary": "Mock opinion",
"aiBestGenre": "Mock genre",
"aiTags": ["mock", "tag"]
}
mock_service = AsyncMock(return_value=mock_service_return)
mocker.patch('app.graphql.resolvers.analyze_movie_v2.analyze_movie', mock_service)
# Simule une requête complète
mocker.patch('app.graphql.resolvers.analyze_movie_v2.is_field_requested', return_value=True)
# 2. Appel du resolver
from app.graphql.resolvers.analyze_movie_v2 import analyze_movie_by_id
await analyze_movie_by_id(
movie_id=strawberry.ID("1"),
info=mock_info
)
# 3. Assertions
# Vérifie que le service a été appelé avec TOUS les drapeaux à True
mock_service.assert_called_once_with(
movie_id="1",
ai_summary=True,
ai_opinion_summary=True,
ai_best_genre=True,
ai_tags=True,
llm=mock_info.context["llm"]
)

51
tests/etape7_erreurs.py Normal file
View File

@@ -0,0 +1,51 @@
"""
Tests de validation pour l'Étape 7 (Bonus) : Gestion fine des erreurs métier
Objectif :
1. Vérifier que le service V2 lève bien une `NotFoundBLLException`
si le `movie_repository` retourne `None`.
Prérequis :
- La logique de gestion d'erreur est en place dans
`app/services/movie_analyzer_v2.py`.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock
from app.core.exceptions import NotFoundBLLException
@pytest.mark.asyncio
async def test_service_v2_raises_not_found(mocker):
"""
Vérifie que le service `analyze_movie` lève une `NotFoundBLLException`
si le film n'est pas trouvé dans le repository.
"""
# 1. Mocker les dépendances (repositories)
mock_movie_repo = AsyncMock()
mock_movie_repo.find_by_id.return_value = None # Simule un film non trouvé
mocker.patch('app.services.movie_analyzer_v2.movie_repository', mock_movie_repo)
mock_llm = MagicMock()
# 2. Appel du service en s'attendant à une exception
from app.services.movie_analyzer_v2 import analyze_movie
with pytest.raises(NotFoundBLLException) as exc_info:
await analyze_movie(
movie_id="999",
ai_summary=True, # Peu importe les drapeaux
ai_opinion_summary=False,
ai_best_genre=False,
ai_tags=False,
llm=mock_llm
)
# 3. Assertions
# Vérifie que le repo a bien été appelé
mock_movie_repo.find_by_id.assert_called_once_with("999")
# Vérifie que le type d'exception est correct
assert exc_info.type is NotFoundBLLException
# Vérifie que le message d'erreur contient l'ID
assert "999" in str(exc_info.value)
assert "Movie" in str(exc_info.value)