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
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>
144 lines
6.1 KiB
Python
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)
|