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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
236d6b4b46
commit
3c39f1a249
@@ -0,0 +1,116 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user