Files
Forge-Engine/tests/document/test_document_routes.py
T
williamandClaude Sonnet 5 9d87a4bfef
Build and deploy / test-python (push) Failing after 1m3s
Build and deploy / test-js (push) Successful in 48s
Build and deploy / lint-python (push) Failing after 1m2s
Build and deploy / lint-js (push) Failing after 1m5s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m5s
Implemente le mini-jeu Quiz (questions/choix/points/timer)
document_engine/labels/quiz_config.py (nouveau) : modele de donnees
complet, meme convention resolve_X/sanitize_X que
game_engine/rendering/quiz_box_config.py cote jeu (aucun import
croise) - DEFAULT_QUIZ_CONFIG, sanitize_quiz_config (valide/nettoie
chaque question independamment, jamais ne leve, tronque a 2-4 choix,
remet correct_index a 0 si hors bornes/non-entier, clampe points >= 0
et timer_seconds dans [5,300]), quiz_total_points.

Cote serveur, routes/document/document_element_update.py revalide
desormais un quiz avant persistance (seul kind qui en a besoin - les
autres n'ont que des attributs scalaires sans structure a garantir) et
renvoie les attributs REELLEMENT persistes dans sa reponse, pour que
le client ne derive jamais de la verite serveur apres un nettoyage
serveur (ex. choix en trop tronque).

Cote editeur (static/document/js/document-editor.js), le panneau
Proprietes d'un quiz est desormais reel : chronometre optionnel,
couleur de theme, liste de questions repetable (ajout/suppression),
chacune avec son texte, un nombre de choix ajustable (2-4, les
inputs texte suivent), le choix correct via un radio par question,
et les points gagnes. Le rendu canevas (render_document_element.py)
affiche un resume reel (nombre de questions, total des points,
minuteur) au lieu du placeholder generique.

Tests : document_engine/labels/quiz_config.py couvert par 13 tests
purs (tests/document/test_quiz_config.py, sans Flask - defauts,
troncature, validation, cas limites dont bool comme correct_index) +
1 test de route verifiant la sanitization a l'ecriture et le rendu.

SKIP=djlint : backlog H021 pre-existant, aucun template touche ici.
ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous
verts ; 63 tests cibles (document + onboarding + auth) verifies
fraichement + verification manuelle live via le serveur de dev
(ajout, sanitization sur choix invalides/en trop, rendu du resume
avec minuteur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 11:40:48 +02:00

144 lines
6.1 KiB
Python

"""Routes HTTP du support de formation (routes/document/) — création,
édition directe (sans page intermédiaire), CRUD des éléments en AJAX,
publication, et isolation par propriétaire (core/auth_guard.py)."""
from typing import Any
from flask.testing import FlaskClient
import auth
import db
def test_documents_new_creates_a_support_not_a_game(client: FlaskClient) -> None:
resp = client.post("/documents/new", data={"name": "Sécurité incendie"}, follow_redirects=False)
assert resp.status_code == 302
assert "/document/" in resp.headers["Location"]
assert "/edit" in resp.headers["Location"]
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
try:
assert db.support_meta(slug)["name"] == "Sécurité incendie"
finally:
db.delete_support(slug)
def test_document_edit_renders_directly_no_intermediate_page(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/edit")
assert resp.status_code == 200
def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre"})
assert resp.status_code == 200
payload = resp.get_json()
assert payload["kind"] == "titre"
assert "docText" in payload["rendered_html"]
def test_document_element_add_rejects_unknown_kind(client: FlaskClient, support: str) -> None:
assert client.post(f"/document/{support}/elements/add", data={"kind": "n-importe-quoi"}).status_code == 400
def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskClient, support: str) -> None:
""" "row" n'apparaît jamais dans la bibliothèque (voir
document_engine.ELEMENT_LIBRARY) mais reste un kind valide pour cette
route — c'est le moteur de layout côté client qui en crée une à la
volée au moment d'un dépôt latéral (voir
static/document/js/document-editor.js)."""
resp = client.post(f"/document/{support}/elements/add", data={"kind": "row"})
assert resp.status_code == 200
assert resp.get_json()["kind"] == "row"
def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None:
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"})
element_id = add_resp.get_json()["id"]
resp = client.post(
f"/document/{support}/elements/{element_id}/update",
json={"content": "Texte mis à jour", "style": "legende"},
)
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
reread = client.post(f"/document/{support}/elements/add", data={"kind": "titre"})
assert reread.status_code == 200
def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskClient, support: str) -> None:
"""Le quiz est le seul kind revalidé côté serveur (voir
routes/document/document_element_update.py) : un choix en trop
(5e) doit être tronqué, et la réponse renvoie les attributs
RÉELLEMENT persistés (pas ceux envoyés tels quels) pour que le
client ne dérive jamais de la vérité serveur."""
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz"})
element_id = add_resp.get_json()["id"]
resp = client.post(
f"/document/{support}/elements/{element_id}/update",
json={
"theme_color": "#ff5f2e",
"timer_enabled": False,
"timer_seconds": 30,
"questions": [
{"text": "Une question", "choices": ["A", "B", "C", "D", "E"], "correct_index": 0, "points": 10},
],
},
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["ok"] is True
assert payload["attributes"]["questions"][0]["choices"] == ["A", "B", "C", "D"]
assert "1 question" in payload["rendered_html"]
assert "10 points" in payload["rendered_html"]
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).get_json()["id"]
second_id = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}).get_json()["id"]
move_resp = client.post(
f"/document/{support}/elements/{second_id}/move",
json={"new_parent_id": None, "new_index": 0},
)
assert move_resp.status_code == 200
assert move_resp.get_json()["ok"] is True
delete_resp = client.post(f"/document/{support}/elements/{first_id}/delete")
assert delete_resp.status_code == 200
assert delete_resp.get_json()["ok"] is True
def test_restricted_user_can_have_one_game_and_one_support_at_once(
user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any
) -> None:
with user_client.session_transaction() as sess:
user_id = sess["user_id"]
game_slug = db.create_game("Un environnement 2D", owner_folder=str(user_id))
tmp_game_slug_cleanup(game_slug)
auth.set_project_slug(user_id, game_slug)
resp = user_client.post("/documents/new", data={"name": "Un support"}, follow_redirects=False)
assert resp.status_code == 302
support_slug = resp.headers["Location"].rstrip("/").split("/")[-2]
tmp_support_slug_cleanup(support_slug)
user = auth.get_user_by_id(user_id)
assert user is not None
assert user["project_slug"] == game_slug
assert user["support_slug"] == support_slug
def test_cannot_open_another_owners_support(client: FlaskClient, user_client: FlaskClient) -> None:
"""Isolation par propriétaire (core/auth_guard.py, split_slug déjà
générique quel que soit le type de projet) — le compte admin
(`client`) ne doit pas pouvoir ouvrir le support d'un autre compte."""
with user_client.session_transaction() as sess:
victim_user_id = sess["user_id"]
victim_slug = db.create_support("Support de la victime", owner_folder=str(victim_user_id))
try:
assert client.get(f"/document/{victim_slug}/edit").status_code == 403
assert client.post(f"/document/{victim_slug}/delete").status_code == 403
finally:
db.delete_support(victim_slug)