Files
Forge-Engine/tests/test_public_play.py
T
williamandClaude Sonnet 5 3c39f1a249
Build and deploy / test (push) Failing after 8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Phase 1 (2/3) : route publique /jouer/<slug> + identité visiteur
Ajoute la vraie route hébergée multi-joueurs qui manquait totalement
(voir le constat d'exploration : /game/<slug>/play est réservé au
créateur connecté, "Publier" ne génère qu'un exécutable mono-joueur) —
un visiteur anonyme peut maintenant jouer un jeu explicitement publié en
ligne, avec sa propre partie (variables/objets per_player posés dans le
commit précédent).

core/player_identity.py : identité visiteur via un cookie NON SIGNÉ
(forge_player_id, secrets.token_urlsafe(16), 1 an) — une simple clé de
partition, jamais un jeton d'autorisation.

routes/public_play/ : 4 routes, miroirs des routes existantes de l'aperçu
créateur mais threadées avec le vrai player_id du cookie au lieu du
sentinel PLAYER_SHARED :
- GET /jouer/<slug> (game_play_public.py)
- GET /jouer/<slug>/runtime-payload
- POST /jouer/<slug>/flow/nodes/<id>/run-data
- POST /jouer/<slug>/flow/nodes/<id>/run-variable
Chacune vérifie elle-même db.is_public_played(slug) (404 sinon) — un jeu
n'est exposé publiquement que si le créateur l'a explicitement basculé
"Publier en ligne" (nouveau db/games/is_public_played.py, réutilise la
table générique _meta, comme game_meta.py pour 'name').

core/auth_guard.py : les 4 endpoints publics ajoutés à _PUBLIC_ENDPOINTS
— la garde générique de connexion les laisse passer sans session, mais
chaque vue vérifie quand même is_public_played elle-même (défense en
profondeur, pas seulement une liste d'exceptions). CSRF (core/csrf_guard.py)
n'a besoin d'AUCUN changement : le jeton est déjà lié à la session Flask,
qui existe pour n'importe quel visiteur (connecté ou non).

templates/play.html : FORGE_PLAY_URLS (posé en Phase -1) ne construit
plus ses URLs via des noms de endpoint fixes (url_for('runtime_payload',
...)) mais reçoit des URLs déjà résolues par la route elle-même
(runtime_payload_url/flow_node_run_data_url/flow_node_run_variable_url)
— nécessaire puisque ce même template sert maintenant DEUX familles de
routes (aperçu créateur ET partie publique), chacune avec ses propres
noms de endpoint. routes/play/game_play.py (aperçu créateur, INCHANGÉ
comportement) et game_play_public.py passent chacun ses propres URLs.

templates/base.html : bascule "🌐 Publier en ligne" dans la barre de
navigation du jeu, à côté de "📦 Publier" (export .zip) — deux
fonctionnalités distinctes. db/games/game_meta.py expose maintenant
is_public_played, disponible partout où `game` est dans le contexte.

Vérifié : 237 tests passent (5 nouveaux dans test_public_play.py, dont un
bout-en-bout via HTTP avec deux VRAIS clients de test anonymes — deux
cookies forge_player_id différents — qui obtiennent des valeurs de
variable indépendantes, et un qui verrouille que l'aperçu créateur reste
inchangé). Syntaxe JS validée sur les deux variantes de play.html rendu
(aperçu créateur et partie publique).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 15:46:00 +02:00

117 lines
4.8 KiB
Python

"""Tests de la route publique /jouer/<slug> (routes/public_play/) — état
par joueur, Phase 1. Contrairement aux autres tests, ceux-ci utilisent
DIRECTEMENT flask_app.test_client() (pas la fixture `client`) pour de
vrais visiteurs anonymes, jamais connectés — le but est justement de
vérifier que core/auth_guard.py laisse passer ces routes sans session."""
import json
import re
import db
from conftest import flask_app
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
content_type="application/json",
)
assert resp.status_code == 200
return resp.get_json()["id"]
def test_public_route_404_until_explicitly_published(client, game):
anon = flask_app.test_client()
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 404
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 200
client.post(f"/game/{game}/toggle-public-played", data={"value": "0"})
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 404
def test_public_route_works_without_any_login_session(client, game):
"""La garde globale (core/auth_guard.py) ne doit PAS exiger de session
pour cette route — c'est tout le sens de "publique"."""
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
anon = flask_app.test_client()
with anon.session_transaction() as sess:
assert "user_id" not in sess
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 200
assert b"CSRF_TOKEN" in resp.data or b"gameData" in resp.data
def test_public_route_sets_player_id_cookie(client, game):
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
anon = flask_app.test_client()
resp = anon.get(f"/jouer/{game}")
assert "forge_player_id" in resp.headers.get("Set-Cookie", "")
def test_two_anonymous_players_get_independent_variable_state(client, game):
"""Bout en bout, via HTTP : deux visiteurs anonymes (deux clients de
test SÉPARÉS, donc deux cookies forge_player_id différents) sur le
même jeu publié -> chacun sa propre valeur pour une variable
per_player, modifiée via l'action "Modifier une variable" (le vrai
chemin d'exécution du mode jouable, routes/public_play/
flow_node_run_variable_public.py)."""
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id,
node_type="action", action_type="modifier_variable",
target_variable="score", data_operation="incrementer", data_value="10",
)
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
alice = flask_app.test_client()
alice.get(f"/jouer/{game}") # pose le cookie
resp = alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
assert resp.get_json()["ok"] is True
bob = flask_app.test_client()
bob.get(f"/jouer/{game}")
resp = bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
resp = bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
assert resp.get_json()["ok"] is True
assert db.get_global_variable(game, "score", player_id="__shared__")["value"] == "0"
alice_payload = alice.get(f"/jouer/{game}/runtime-payload").get_json()
bob_payload = bob.get(f"/jouer/{game}/runtime-payload").get_json()
assert alice_payload["variables"]["score"]["value"] == "10"
assert bob_payload["variables"]["score"]["value"] == "20"
def test_creator_preview_unaffected_by_public_players(client, game):
"""L'aperçu créateur (/game/<slug>/play, connecté) doit continuer de
voir/modifier PLAYER_SHARED, jamais l'état d'un joueur public."""
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id,
node_type="action", action_type="modifier_variable",
target_variable="score", data_operation="incrementer", data_value="10",
)
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
alice = flask_app.test_client()
alice.get(f"/jouer/{game}")
alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
resp = client.get(f"/game/{game}/play")
assert resp.status_code == 200
creator_payload = client.get(f"/game/{game}/runtime-payload").get_json()
assert creator_payload["variables"]["score"]["value"] == "0"