""" Fixtures pytest partagées. Les tests utilisent le VRAI client de test Flask (`app.test_client()`) et de VRAIES bases SQLite créées sous projects/ (comme en production) — rien n'est simulé. Chaque jeu de test est créé avec un nom préfixé "pytest_" et son dossier est supprimé automatiquement à la fin du test (fixture `game`), pour ne jamais laisser de données de test dans projects/. """ import os import shutil import sys import tempfile from collections.abc import Callable, Generator from typing import Any, TypeVar from flask.testing import FlaskClient sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Base de comptes (auth/) et clé de session ISOLÉES du poste réel — posé # AVANT tout import de core.flask_app/auth (chemin résolu à chaque appel, # voir auth/connection.py, mais réglé ici une bonne fois pour toute la # session de tests, jamais data/users.db). Sans ça, faire tourner la # suite de tests écrirait de vrais comptes dans la vraie base de comptes, # et la logique "premier compte = admin" (auth/create_user.py) serait déjà # consommée par un run précédent. _TEST_AUTH_DIR = tempfile.mkdtemp(prefix="forge_test_auth_") os.environ["FORGE_USERS_DB_PATH"] = os.path.join(_TEST_AUTH_DIR, "users.db") os.environ["FORGE_SECRET_KEY_PATH"] = os.path.join(_TEST_AUTH_DIR, "secret_key") # "Mes assets" (voir auth/user_assets_dir.py, db/constants.py::USER_ASSETS_DIR) : # même raison que FORGE_USERS_DB_PATH ci-dessus — jamais écrire dans le # vrai user_assets/ du poste pendant les tests. os.environ["FORGE_USER_ASSETS_DIR"] = os.path.join(_TEST_AUTH_DIR, "user_assets") import pytest # noqa: E402 - doit venir apres les variables d'environnement ci-dessus import auth # noqa: E402 - doit venir apres les variables d'environnement ci-dessus import db # noqa: E402 - doit venir apres les variables d'environnement ci-dessus import routes # noqa: F401, E402 - enregistre toutes les routes sur flask_app from core import ( # noqa: E402 - doit venir apres les variables d'environnement ci-dessus auth_guard, # noqa: F401 - enregistre la garde de connexion (après les routes) csrf, # noqa: F401 - enregistre csrf_token() comme variable globale Jinja csrf_guard, # noqa: F401 - enregistre la vérification du jeton CSRF (désactivée en TESTING, voir test_csrf.py) db_teardown_guard, # noqa: F401 - enregistre la fermeture des connexions SQLite fuitées jinja_filters, # noqa: F401 - enregistre les filtres Jinja (colname, truncatesentences) recovery_codes_flash, # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja ) from core.flask_app import app as flask_app # noqa: E402 - doit venir apres les variables d'environnement ci-dessus # Onglet "IA" (voir ai/) : jamais un vrai appel réseau depuis les tests, # même si un .env réel existe sur ce poste avec de vraies clés — # load_dotenv() (core.flask_app, importé ci-dessus) les aurait déjà # chargées dans os.environ à ce stade ; on les retire explicitement pour # que CHAQUE test parte de "IA non configurée" par défaut. Un test qui # veut simuler une IA configurée monkeypatche directement # ai.client.get_client/ai.scenario_client.generate_image_url — jamais via # une vraie clé, quel que soit le contenu du .env local. for _ai_env_key in ("ANTHROPIC_API_KEY", "SCENARIO_API_KEY", "SCENARIO_API_SECRET", "SCENARIO_MODEL_ID"): os.environ.pop(_ai_env_key, None) # Un seul compte admin pour toute la session de tests : les ~80 routes # existantes s'attendent (comportement d'avant l'authentification) à un # accès sans restriction — "admin" est le seul rôle illimité (voir # core/auth_guard.py). Créé directement en Python (pas via /register) : # ces tests exercent le RESTE du moteur, pas le parcours d'inscription # lui-même (qui a ses propres tests dédiés, voir test_auth.py). _TEST_ADMIN_ID = auth.create_user("admin@pytest.local", "Test1234!", "Test", "Admin") auth.confirm_totp(_TEST_ADMIN_ID) _T = TypeVar("_T") def not_none(value: _T | None) -> _T: """Pour les endroits où Mypy voit un `X | None` (ex. db.get_xxx) mais où le test sait, par construction, que la valeur existe forcément (vient d'être créée/insérée juste au-dessus) — lève AssertionError (message clair) plutôt qu'un TypeError cru si jamais cette hypothèse était fausse.""" assert value is not None return value @pytest.fixture def tmp_game_slug_cleanup() -> Generator[Callable[[str], str], None, None]: """Pour les tests qui appellent db.create_game(...) directement (sans passer par une route Flask) : enregistre un slug à nettoyer à la fin du test, quel que soit son résultat.""" slugs = [] def register(slug: str) -> str: slugs.append(slug) return slug yield register for slug in slugs: game_dir = db.game_dir(slug) if os.path.isdir(game_dir): shutil.rmtree(game_dir) @pytest.fixture def tmp_support_slug_cleanup() -> Generator[Callable[[str], str], None, None]: """Mirroir de tmp_game_slug_cleanup pour un support de formation (db.create_support(...)) — système de fichiers séparé (db.supports.support_dir), jamais db.game_dir (qui résoudrait vers le mauvais dossier pour un slug de support).""" import db.supports slugs = [] def register(slug: str) -> str: slugs.append(slug) return slug yield register for slug in slugs: support_dir = db.supports.support_dir(slug) if os.path.isdir(support_dir): shutil.rmtree(support_dir) @pytest.fixture(autouse=True) def _cleanup_admin_assets() -> Generator[None, None, None]: """Filet de sécurité : le compte admin (`_TEST_ADMIN_ID`) est PARTAGÉ par toute la session de tests (voir plus haut) — un test qui crée un "asset" dessus (upload, outil IA...) sans le nettoyer lui-même pollue tous les tests suivants. Complète (ne remplace pas) les nettoyages ponctuels déjà faits dans le corps de certains tests : ceux-ci restent nécessaires quand le test vérifie un invariant sur la liste des assets AVANT sa propre fin (ce filet ne rattrape qu'après coup). `_TEST_ADMIN_ID` est fixe pour toute la session (un seul admin existe, voir plus haut) — pas besoin de le résoudre dynamiquement par test.""" before = {asset["id"] for asset in auth.list_user_assets(_TEST_ADMIN_ID)} yield for asset in auth.list_user_assets(_TEST_ADMIN_ID): if asset["id"] not in before: auth.delete_user_asset(asset["id"], _TEST_ADMIN_ID) @pytest.fixture def client() -> Generator[FlaskClient, None, None]: flask_app.config["TESTING"] = True with flask_app.test_client() as c: with c.session_transaction() as sess: sess["user_id"] = _TEST_ADMIN_ID yield c _TEST_USER_COUNTER = [0] @pytest.fixture def user_client() -> Generator[FlaskClient, None, None]: """Un compte "user" (jamais le premier de la session de tests, donc jamais admin — voir auth/create_user.py), connecté mais SANS projet assigné pour l'instant (voir la fixture `user_game` ci-dessous) — pour les tests qui vérifient qu'un rôle non-admin voit/peut moins de choses qu'un admin (ex. galerie de sprites animaux CraftPix, voir core/sprite_gate.py).""" _TEST_USER_COUNTER[0] += 1 email = f"pytest-user-{_TEST_USER_COUNTER[0]}@pytest.local" user_id = auth.create_user(email, "Test1234!", "Test", "User") auth.confirm_totp(user_id) flask_app.config["TESTING"] = True with flask_app.test_client() as c: with c.session_transaction() as sess: sess["user_id"] = user_id yield c @pytest.fixture def user_game(user_client: FlaskClient) -> Generator[str, None, None]: """Un jeu appartenant réellement au compte `user_client` — le dossier PROPRIÉTAIRE (voir db/games/project_slug.py) doit correspondre à l'id de CE compte (core/auth_guard.py compare désormais les deux) : créé directement avec `owner_folder=str(user_id)`, jamais via le client ADMIN (qui produirait un projet appartenant à l'admin, avec juste `project_slug` réassigné dessus côté "user" — cassait l'isolation par propriétaire dès qu'elle a cessé d'être une simple comparaison de chaîne).""" with user_client.session_transaction() as sess: user_id = sess["user_id"] slug = db.create_game("pytest_user_game", owner_folder=str(user_id)) auth.set_project_slug(user_id, slug) yield slug if os.path.isdir(db.game_dir(slug)): db.delete_game(slug) @pytest.fixture def game(client: FlaskClient) -> Generator[str, None, None]: """Crée un jeu de test frais et le supprime après le test, quel que soit son résultat (succès ou échec).""" resp = client.post("/games/new", data={"name": "pytest_test_game"}, follow_redirects=False) assert resp.status_code == 302 slug = resp.headers["Location"].rstrip("/").split("/")[-1] yield slug if os.path.isdir(db.game_dir(slug)): db.delete_game(slug) @pytest.fixture def support(client: FlaskClient) -> Generator[str, None, None]: """Crée un support de formation de test frais via /documents/new (mirroir de la fixture `game` ci-dessus, pour l'autre type de projet — entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le test, quel que soit son résultat. Un support neuf n'a plus aucune page par défaut (retour utilisateur du 26/09/2026, voir document_engine/pages/pages.md) — cette fixture lui en crée une par convénience, puisque la quasi-totalité des tests existants portent sur du contenu et présupposent une première page "Page 1" déjà là (comme avant ce changement). Les tests qui portent spécifiquement sur l'état "0 page" créent leur propre support brut via db.create_support(...) plutôt que d'utiliser cette fixture.""" import db.supports import document_engine resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False) assert resp.status_code == 302 slug = resp.headers["Location"].rstrip("/").split("/")[-2] document_engine.add_document_page(slug) yield slug if os.path.isdir(db.supports.support_dir(slug)): db.delete_support(slug) _STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "game", "js") _PLAY_JS_DIR = os.path.join(_STATIC_DIR, "play") _SCREEN_EDIT_JS_DIR = os.path.join(_STATIC_DIR, "screen_edit") def _js_bundle(directory: str) -> str: parts = [] for name in sorted(os.listdir(directory)): path = os.path.join(directory, name) if os.path.isfile(path) and name.endswith(".js"): with open(path, encoding="utf-8") as f: parts.append(f.read()) return "\n".join(parts) def play_js_bundle() -> str: """Concatène tout le JS du moteur jouable (voir le plan de modularisation — static/game/js/play/, un fichier par responsabilité, extrait de l'ancien