Files
Forge-Engine/tests/test_publish.py
T
williamandClaude Sonnet 5 1289079da5 Ajoute "Publier" : exporte un jeu en exécutable Windows autonome
Fonctionnalité mise de côté depuis le tout début de ce chantier
("jouable en toute autonomie"). Nouveau bouton "📦 Publier" dans la barre
de navigation du jeu (après "▶️ Jouer") : construit un zip contenant un
Python portable + Flask embarqués, une copie figée du moteur de rendu
(screens/db/filters + le strict minimum de core/) et des données du jeu
(game.db + uploads/), lancé en double-cliquant sur run.bat — aucune
installation requise, 100% hors-ligne (voir le commit précédent qui a
auto-hébergé polices/animate.css, dernière dépendance CDN de l'app).

Exploration préalable a confirmé que screens/ et db/ sont déjà
totalement découplés de auth/routes/core (seul lien : un try/except
optionnel dans db/connection.py) et que templates/play.html n'appelle
que 4 endpoints "purs" (aucune logique d'auth mélangée dedans) — ce qui
a permis de réutiliser ces routes quasiment telles quelles dans un
mini-serveur Flask séparé plutôt que de les réécrire.

- db/constants.py : PROJECTS_DIR devient surchargeable via
  FORGE_PROJECTS_DIR (même schéma que auth/connection.py) — le serveur
  joueur autonome pointe ainsi vers son propre dossier "projects/"
  embarqué.
- publish/vendor_runtime.py : télécharge (une fois par poste, mis en
  cache sous data/publish_vendor/ — déjà ignoré par git) le ZIP Python
  embeddable officiel (python.org) et vendore Flask via pip --target ;
  fonctions séparées et mockables pour ne jamais déclencher de vrai
  téléchargement dans les tests.
- publish/player_app_template.py : mini-Flask autonome, réutilise
  core/flask_app.py et core/jinja_filters.py tels quels (SLUG figé en
  dur au moment de la publication, csrf_token() factice puisqu'aucune
  session n'existe dans cet export). sys.path doit être complété
  manuellement au démarrage : le python311._pth de la distribution
  embeddable ne référence que le dossier de python.exe lui-même, jamais
  celui du script lancé.
- publish/build_package.py : assemble le zip dans un dossier temporaire
  (jamais les vrais fichiers de l'app), copié/nettoyé après envoi de la
  réponse HTTP (routes/publish/publish_game.py, déjà protégée par la
  garde d'accès existante — aucune vérification supplémentaire).
- templates/base.html : bouton + modale (avancement séquentiel, jamais
  de suivi serveur réel — le build est rapide) qui déclenche le
  téléchargement du zip via un blob, comme la modale des codes de
  récupération déjà en place (posée À L'INTÉRIEUR de <main> pour que
  pjax.js la remplace et rejoue son script à chaque navigation).

Vérifié pour de vrai (pas seulement via les tests) : zip construit,
extrait, lancé avec le Python embeddable réel — /, /game/test/play,
/game/test/runtime-payload et /static/style.css répondent tous 200.

tests/test_publish.py (nouveau, vendor mocké) : structure du zip,
SLUG correctement substitué, route protégée par la même isolation par
projet que le reste de l'app. 203 tests au total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 09:58:32 +02:00

92 lines
3.8 KiB
Python

"""Publier un jeu en exécutable Windows autonome (bouton "Publier", voir
routes/publish/publish_game.py et publish/build_package.py). Le vrai
téléchargement du Python portable + Flask (publish/vendor_runtime.py)
n'a JAMAIS lieu ici : `ensure_vendor_ready` est monkeypatché vers un
dossier factice minuscule — ces tests vérifient l'ASSEMBLAGE du zip
(fichiers présents, structure), jamais un vrai lancement du serveur
embarqué (nécessite un environnement Windows réel, voir le plan)."""
import os
import zipfile
import pytest
import publish.build_package as build_package
from tests.test_auth import anon_client # noqa: F401
@pytest.fixture
def fake_vendor(tmp_path, monkeypatch):
"""Un python-embed/pylibs factice, juste assez pour que l'assemblage
du zip (copie de fichiers) fonctionne sans jamais toucher au réseau."""
python_dir = tmp_path / "python-embed-amd64"
python_dir.mkdir()
(python_dir / "python.exe").write_bytes(b"fake-exe")
pylibs_dir = tmp_path / "pylibs"
pylibs_dir.mkdir()
(pylibs_dir / "flask").mkdir()
(pylibs_dir / "flask" / "__init__.py").write_text("# fake", encoding="utf-8")
def _fake_ensure_vendor_ready():
return str(python_dir), str(pylibs_dir)
monkeypatch.setattr(build_package.vendor_runtime, "ensure_vendor_ready", _fake_ensure_vendor_ready)
return python_dir, pylibs_dir
def test_build_game_zip_contains_the_expected_files(fake_vendor, game):
zip_path, game_name = build_package.build_game_zip(game)
try:
assert game_name # nom du jeu (pytest_test_game par défaut, voir conftest.py)
with zipfile.ZipFile(zip_path) as zf:
names = set(zf.namelist())
for expected in [
"run.bat", "player_app.py",
f"projects/{game}/game.db",
"screens/__init__.py", "db/__init__.py", "filters/__init__.py",
"core/__init__.py", "core/flask_app.py", "core/jinja_filters.py",
"templates/play.html",
"static/style.css", "static/csrf_fetch.js",
"python-embed/python.exe", "python-embed/pylibs/flask/__init__.py",
]:
assert expected in names, f"{expected} manquant du zip"
finally:
if os.path.isfile(zip_path):
os.remove(zip_path)
def test_build_game_zip_bakes_the_slug_into_player_app(fake_vendor, game):
zip_path, _ = build_package.build_game_zip(game)
try:
with zipfile.ZipFile(zip_path) as zf:
player_app_src = zf.read("player_app.py").decode("utf-8")
assert f'SLUG = "{game}"' in player_app_src
assert "_SLUG_PLACEHOLDER" not in player_app_src
finally:
if os.path.isfile(zip_path):
os.remove(zip_path)
def test_publish_route_returns_a_zip_attachment(fake_vendor, client, game):
resp = client.post(f"/game/{game}/publish")
assert resp.status_code == 200
assert resp.headers["Content-Type"] == "application/zip"
assert "attachment" in resp.headers["Content-Disposition"]
assert resp.data[:2] == b"PK" # signature d'un fichier zip
def test_publish_route_is_isolated_like_other_game_routes(fake_vendor, anon_client):
"""Réutilise la garde d'accès existante (core/auth_guard.py) sans
code supplémentaire — un compte non-admin ne doit pas pouvoir publier
le projet d'un autre (même vérification que /game/<slug> lui-même,
voir tests/test_auth.py::test_non_admin_user_is_isolated_to_their_own_project)."""
from tests.test_auth import _register, _confirm_2fa, _cleanup_project
_register(anon_client, "publishisolation@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish")
assert resp.status_code == 403
finally:
_cleanup_project("publishisolation@example.com")