diff --git a/.eslintrc.json b/.eslintrc.json
index 4f33db4c..4f033a2f 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -5,7 +5,7 @@
"es2021": true
},
"extends": ["airbnb-base"],
- "plugins": ["unused-imports"],
+ "plugins": ["unused-imports", "unicorn"],
"parserOptions": {
"ecmaVersion": 2021,
"sourceType": "script"
@@ -48,7 +48,16 @@
"no-void": "off",
"no-continue": "off",
"implicit-arrow-linebreak": "off",
- "no-use-before-define": ["error", { "functions": false, "classes": true, "variables": true }]
+ "no-use-before-define": ["error", { "functions": false, "classes": true, "variables": true }],
+ "unicorn/prefer-number-properties": "error",
+ "unicorn/prefer-string-replace-all": "error",
+ "unicorn/prefer-dom-node-dataset": "error",
+ "unicorn/prefer-includes": "error",
+ "unicorn/prefer-string-starts-ends-with": "error",
+ "unicorn/prefer-modern-math-apis": "error",
+ "unicorn/prefer-at": "error",
+ "unicorn/no-useless-fallback-in-spread": "error",
+ "unicorn/no-for-loop": "error"
},
"overrides": [
{
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index f6fa181a..1501629f 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -114,7 +114,7 @@ jobs:
RUN ruff format --check .
RUN mypy .
RUN vulture
- RUN bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine scripts app.py build_css.py
+ RUN bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py
RUN lint-imports
DOCKERFILE
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 07cf6fcc..3dcd05de 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -51,7 +51,7 @@ repos:
- id: bandit
name: Bandit (securite Python)
- entry: bandit -c pyproject.toml -r ai auth core db filters publish routes screens scripts app.py build_css.py
+ entry: bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py
language: system
pass_filenames: false
diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md
index 95a28571..7b082234 100644
--- a/CODE_QUALITY.md
+++ b/CODE_QUALITY.md
@@ -19,7 +19,7 @@ ignore de fichier entier ou de règle globale — voir section 4.
| **Ruff** (lint) | Erreurs Python, imports inutilisés, style | `*.py` | Obligatoire (bloquant CI + pre-commit) |
| **Ruff** (format) | Formatage Python | `*.py` | Obligatoire |
| **Mypy** `--strict` | Typage statique Python | `*.py` | Obligatoire |
-| **Bandit** | Sécurité Python (injections, primitives faibles) | `ai`, `auth`, `core`, `db`, `filters`, `publish`, `routes`, `game_engine`, `scripts`, `app.py`, `build_css.py` | Obligatoire |
+| **Bandit** | Sécurité Python (injections, primitives faibles) | `ai`, `auth`, `core`, `db`, `filters`, `publish`, `routes`, `game_engine`, `document_engine`, `scripts`, `app.py`, `build_css.py` | Obligatoire |
| **Vulture** | Code mort Python | mêmes dossiers que Bandit | Obligatoire |
| **import-linter** | Contrat de couches applicatives | tout le code Python | Obligatoire |
| **ESLint** (`airbnb-base`) | Lint JavaScript | `static/js/**/*.js` | Obligatoire |
@@ -42,7 +42,7 @@ ignore de fichier entier ou de règle globale — voir section 4.
### Bandit (`pyproject.toml`, `[tool.bandit]`)
- `exclude_dirs` : `projects`, `user_assets`, `data`, `Bug`, `regles`, `tests` (les tests contiennent des mots de passe/tokens de test en dur, jamais un vrai risque).
-- Portée explicite (`-r ai auth core db filters publish routes game_engine scripts app.py build_css.py`) plutôt que tout le dépôt.
+- Portée explicite (`-r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py`) plutôt que tout le dépôt.
### Vulture (`pyproject.toml`, `[tool.vulture]`)
- `min_confidence = 80`, `paths` = mêmes dossiers que Bandit + `vulture_whitelist.py`.
@@ -50,7 +50,7 @@ ignore de fichier entier ou de règle globale — voir section 4.
- Faux positifs structurels (routes Flask enregistrées par décorateur, hooks appelés par convention de nom) : whitelist dédiée dans `vulture_whitelist.py`, **jamais** un `min_confidence` abaissé globalement.
### import-linter (`pyproject.toml`, `[tool.importlinter]`)
-- Un seul contrat de type `layers`, du haut vers le bas : `routes` → `ai | publish | core` → `game_engine` → `auth | filters` → `db`.
+- Un seul contrat de type `layers`, du haut vers le bas : `routes` → `ai | publish | core` → `game_engine | document_engine` → `auth | filters` → `db`.
- Une couche ne peut importer qu'une couche strictement en dessous d'elle, jamais au-dessus, jamais une couche sœur du même niveau.
- `app.py` (point d'entrée, pas un paquet) reste hors contrat — c'est lui qui importe `routes`/`core`, jamais l'inverse.
@@ -94,7 +94,7 @@ ruff check .
ruff format --check .
mypy .
vulture
-bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine scripts app.py build_css.py
+bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py
lint-imports
djlint templates
npx eslint "static/js/**/*.js"
@@ -141,7 +141,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
| `static/game/js/scenes/scene-editor.js:214` (`CURRENT_SELECTED_ID = null`), `static/game/js/screen_edit/tree-panels.js:392` (`CURRENT_SELECTED_ID = selectedId \|\| null`) | `javascript:S2703` | Déclarée en **`var`** (pas `let`/`const`) dans `templates/game/scene_edit.html:833` (`var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};`), chargé avant `tree-panels.js`/`scene-editor.js`/`trigger-editor.js`/`collision-rules-editor.js` (ordre vérifié, lignes 833/858/880/885/887/888) — un `var` de script classique attache directement à `window`, réassignable sans aucune restriction depuis n'importe quel autre `` (voir Phase 3). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `` (voir Phase 3) ; (3) `rendered_document` (`document_edit.html`, ajouté le 20/09/2026) — même sous-groupe (1) : produit par `document_engine.render_document`, qui échappe (`html.escape()`) tout contenu utilisateur avant interpolation (voir `document_engine/rendering/render_document_element.py`). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `
+
+{% endblock %}
diff --git a/templates/index.html b/templates/index.html
index 4ddc5ca5..14924b9b 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -1,54 +1,95 @@
{% extends "base.html" %}
-{% block title %}Forge Engine — Mes jeux{% endblock %}
+{% block title %}Forge Engine — Mes formations{% endblock %}
{% block content_class %}content-wide content-objectEdit{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block content %}
-
-
-
-
-
Créer un jeu
-
-
-
Choisis un type de jeu, nomme-le : Forge Engine crée le projet et t'envoie directement sur l'éditeur adapté.
-
+ volonté (illimité, aucun slot dédié qui bloquerait le retour).
+ Un compte "user" restreint peut posséder AU PLUS un projet de
+ chaque type (environnement 2D + support de formation, voir
+ docs/plan/PLAN.md — deux entités racines séparées, jamais
+ mélangées). -->
+
+
Choisis un type de projet, nomme-le : Forge Engine le crée et t'envoie directement sur l'éditeur adapté.
+
+
+
+
+
Environnements 2D
+ {% if games %}
+
+
+
+
+ Nom
+ Dossier
+
+
+
+
+ {% for g in games %}
+
+ {{ g.name }}
+ projects/{{ g.slug }}/
+
+
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
Aucun environnement 2D pour l'instant — crée-en un à gauche.
+ {% endif %}
+
Supports de formation
+ {% if supports %}
+
+
+
+
+ Nom
+ Dossier
+
+
+
+
+ {% for s in supports %}
+
+ {{ s.name }}
+ projects/{{ s.slug }}/
+
+
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
Aucun support de formation pour l'instant — crée-en un à gauche.
+ {% endif %}
-
-
-
Jeux existants
- {% if games %}
-
-
- Nom Dossier
-
- {% for g in games %}
-
- {{ g.name }}
- projects/{{ g.slug }}/
-
-
-
-
- {% endfor %}
-
-
-
- {% else %}
-
Aucun jeu pour l'instant — crée le premier à gauche.
- {% endif %}
-
-
{% endblock %}
diff --git a/templates/onboarding/onboarding_new.html b/templates/onboarding/onboarding_new.html
index 626bd64b..9ed8895f 100644
--- a/templates/onboarding/onboarding_new.html
+++ b/templates/onboarding/onboarding_new.html
@@ -1,5 +1,5 @@
{% extends "base.html" %}
-{% block title %}Forge Engine — Créer mon jeu{% endblock %}
+{% block title %}Forge Engine — Créer mon projet{% endblock %}
{% block body_class %}objectEditBody{% endblock %}
{% block content_class %}content-wide onboardingPage{% endblock %}
{% block content %}
@@ -12,7 +12,7 @@
- Quel jeu veux-tu créer ?
+ Que veux-tu créer ?
{% for key, info in onboarding_types.items() %}
@@ -33,9 +33,9 @@
diff --git a/tests/conftest.py b/tests/conftest.py
index 39e0da4b..f68a7c98 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -99,6 +99,27 @@ def tmp_game_slug_cleanup() -> Generator[Callable[[str], str], None, None]:
shutil.rmtree(game_dir)
+@pytest.fixture
+def tmp_support_slug_cleanup() -> Generator[Callable[[str], str], None, None]:
+ """Mirroir de tmp_game_slug_cleanup pour un support de formation
+ (db.create_support(...)) — système de fichiers séparé
+ (db.supports.support_dir), jamais db.game_dir (qui résoudrait vers le
+ mauvais dossier pour un slug de support)."""
+ import db.supports
+
+ slugs = []
+
+ def register(slug: str) -> str:
+ slugs.append(slug)
+ return slug
+
+ yield register
+ for slug in slugs:
+ support_dir = db.supports.support_dir(slug)
+ if os.path.isdir(support_dir):
+ shutil.rmtree(support_dir)
+
+
@pytest.fixture(autouse=True)
def _cleanup_admin_assets() -> Generator[None, None, None]:
"""Filet de sécurité : le compte admin (`_TEST_ADMIN_ID`) est PARTAGÉ
@@ -179,6 +200,22 @@ def game(client: FlaskClient) -> Generator[str, None, None]:
db.delete_game(slug)
+@pytest.fixture
+def support(client: FlaskClient) -> Generator[str, None, None]:
+ """Crée un support de formation de test frais via /documents/new
+ (mirroir de la fixture `game` ci-dessus, pour l'autre type de projet —
+ entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le
+ test, quel que soit son résultat."""
+ import db.supports
+
+ resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False)
+ assert resp.status_code == 302
+ slug = resp.headers["Location"].rstrip("/").split("/")[-2]
+ yield slug
+ if os.path.isdir(db.supports.support_dir(slug)):
+ db.delete_support(slug)
+
+
_STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "game", "js")
_PLAY_JS_DIR = os.path.join(_STATIC_DIR, "play")
_SCREEN_EDIT_JS_DIR = os.path.join(_STATIC_DIR, "screen_edit")
diff --git a/tests/document/test_document_elements.py b/tests/document/test_document_elements.py
new file mode 100644
index 00000000..0ad7eb84
--- /dev/null
+++ b/tests/document/test_document_elements.py
@@ -0,0 +1,140 @@
+"""CRUD des éléments d'un support de formation (document_engine/elements/)
+— sans Flask, directement contre le support.db créé par db.create_support.
+Couvre : CRUD complet, rangées (parent_id), réinsertion (move_document_element
+vers une nouvelle rangée/position), suppression en cascade d'une rangée avec
+enfants, et les attributs de position/taille/rotation d'une forme."""
+
+from typing import Any
+
+import db
+import document_engine as doc_engine
+
+
+def _new_support(tmp_support_slug_cleanup: Any, name: str = "Support de test") -> str:
+ slug = db.create_support(name, owner_folder="90")
+ tmp_support_slug_cleanup(slug)
+ return slug
+
+
+def test_add_and_get_document_element_uses_default_attributes(tmp_support_slug_cleanup: Any) -> None:
+ slug = _new_support(tmp_support_slug_cleanup)
+ element_id = doc_engine.add_document_element(slug, "titre")
+ element = doc_engine.get_document_element(slug, element_id)
+ assert element is not None
+ assert element["kind"] == "titre"
+ assert element["parent_id"] is None
+ assert element["attributes"]["content"] == "Nouveau titre"
+
+
+def test_get_document_element_returns_none_for_missing_id(tmp_support_slug_cleanup: Any) -> None:
+ slug = _new_support(tmp_support_slug_cleanup)
+ assert doc_engine.get_document_element(slug, 999) is None
+
+
+def test_list_document_elements_groups_row_children_together(tmp_support_slug_cleanup: Any) -> None:
+ slug = _new_support(tmp_support_slug_cleanup)
+ row_id = doc_engine.add_document_element(slug, "row")
+ top_titre_id = doc_engine.add_document_element(slug, "titre")
+ child_a_id = doc_engine.add_document_element(slug, "paragraphe", parent_id=row_id)
+ child_b_id = doc_engine.add_document_element(slug, "image", parent_id=row_id)
+
+ elements = doc_engine.list_document_elements(slug)
+ ids_in_order = [el["id"] for el in elements]
+
+ # Les deux top-niveau (row_id, top_titre_id) groupés avant les deux
+ # enfants de la rangée (child_a_id, child_b_id) — tri (parent_id, order_index).
+ assert ids_in_order.index(row_id) < ids_in_order.index(child_a_id)
+ assert ids_in_order.index(top_titre_id) < ids_in_order.index(child_a_id)
+ assert ids_in_order.index(child_a_id) < ids_in_order.index(child_b_id)
+
+
+def test_update_document_element_attributes_replaces_whole_json(tmp_support_slug_cleanup: Any) -> None:
+ slug = _new_support(tmp_support_slug_cleanup)
+ element_id = doc_engine.add_document_element(slug, "paragraphe")
+ doc_engine.update_document_element_attributes(slug, element_id, {"content": "Texte modifié", "style": "legende"})
+ element = doc_engine.get_document_element(slug, element_id)
+ assert element is not None
+ assert element["attributes"] == {"content": "Texte modifié", "style": "legende"}
+
+
+def test_shape_default_attributes_carry_real_position_and_size(tmp_support_slug_cleanup: Any) -> None:
+ slug = _new_support(tmp_support_slug_cleanup)
+ element_id = doc_engine.add_document_element(slug, "rectangle")
+ element = doc_engine.get_document_element(slug, element_id)
+ assert element is not None
+ attrs = element["attributes"]
+ assert attrs["x"] == 40
+ assert attrs["y"] == 40
+ assert attrs["width"] == 160
+ assert attrs["height"] == 100
+ assert attrs["rotation"] == 0
+ assert attrs["z_index"] == 1
+
+ doc_engine.update_document_element_attributes(
+ slug,
+ element_id,
+ {**attrs, "x": 120, "y": 80, "width": 200, "height": 150, "rotation": 45, "z_index": 3},
+ )
+ moved = doc_engine.get_document_element(slug, element_id)
+ assert moved is not None
+ assert moved["attributes"]["x"] == 120
+ assert moved["attributes"]["rotation"] == 45
+ assert moved["attributes"]["z_index"] == 3
+
+
+def test_move_document_element_reorders_within_same_parent(tmp_support_slug_cleanup: Any) -> None:
+ slug = _new_support(tmp_support_slug_cleanup)
+ first_id = doc_engine.add_document_element(slug, "titre")
+ second_id = doc_engine.add_document_element(slug, "paragraphe")
+ third_id = doc_engine.add_document_element(slug, "image")
+
+ doc_engine.move_document_element(slug, third_id, None, 0)
+
+ elements = doc_engine.list_document_elements(slug)
+ top_level = [el for el in elements if el["parent_id"] is None]
+ ordered_ids = [el["id"] for el in sorted(top_level, key=lambda el: el["order_index"])]
+ assert ordered_ids == [third_id, first_id, second_id]
+
+
+def test_move_document_element_reparents_into_a_row(tmp_support_slug_cleanup: Any) -> None:
+ slug = _new_support(tmp_support_slug_cleanup)
+ row_id = doc_engine.add_document_element(slug, "row")
+ lone_titre_id = doc_engine.add_document_element(slug, "titre")
+
+ doc_engine.move_document_element(slug, lone_titre_id, row_id, 0)
+
+ moved = doc_engine.get_document_element(slug, lone_titre_id)
+ assert moved is not None
+ assert moved["parent_id"] == row_id
+ assert moved["order_index"] == 0
+
+
+def test_delete_document_element_cascades_to_row_children(tmp_support_slug_cleanup: Any) -> None:
+ slug = _new_support(tmp_support_slug_cleanup)
+ row_id = doc_engine.add_document_element(slug, "row")
+ child_id = doc_engine.add_document_element(slug, "titre", parent_id=row_id)
+
+ doc_engine.delete_document_element(slug, row_id)
+
+ assert doc_engine.get_document_element(slug, row_id) is None
+ assert doc_engine.get_document_element(slug, child_id) is None
+
+
+def test_delete_document_element_on_missing_id_is_a_noop(tmp_support_slug_cleanup: Any) -> None:
+ slug = _new_support(tmp_support_slug_cleanup)
+ doc_engine.delete_document_element(slug, 999)
+
+
+def test_render_document_includes_row_children_and_shape_position(tmp_support_slug_cleanup: Any) -> None:
+ slug = _new_support(tmp_support_slug_cleanup)
+ row_id = doc_engine.add_document_element(slug, "row")
+ doc_engine.add_document_element(slug, "titre", parent_id=row_id)
+ rect_id = doc_engine.add_document_element(slug, "rectangle")
+
+ html = doc_engine.render_document(doc_engine.list_document_elements(slug))
+
+ assert 'data-kind="row"' in html
+ assert 'data-kind="titre"' in html
+ assert f'data-element-id="{rect_id}"' in html
+ assert "left:40px" in html
+ assert "top:40px" in html
diff --git a/tests/document/test_document_routes.py b/tests/document/test_document_routes.py
new file mode 100644
index 00000000..851b99d4
--- /dev/null
+++ b/tests/document/test_document_routes.py
@@ -0,0 +1,124 @@
+"""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_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_document_publish_persists_published_at(client: FlaskClient, support: str) -> None:
+ assert db.support_meta(support)["published_at"] is None
+ resp = client.post(f"/document/{support}/publish")
+ assert resp.status_code == 200
+ published_at = resp.get_json()["published_at"]
+ assert published_at is not None
+ assert db.support_meta(support)["published_at"] == published_at
+
+
+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)
diff --git a/tests/document/test_support_lifecycle.py b/tests/document/test_support_lifecycle.py
new file mode 100644
index 00000000..e9fdfb5c
--- /dev/null
+++ b/tests/document/test_support_lifecycle.py
@@ -0,0 +1,70 @@
+"""Cycle de vie d'un support de formation (db/supports/) — mirroir des
+tests de db/games/, mais vérifie en plus l'ISOLATION stricte entre les
+deux systèmes : un support ne doit jamais apparaître dans db.list_games()
+et un jeu ne doit jamais apparaître dans db.list_supports(), même pour le
+même propriétaire."""
+
+from typing import Any
+
+import db
+
+
+def test_create_support_creates_its_own_db_file_and_schema(tmp_support_slug_cleanup: Any) -> None:
+ slug = db.create_support("Sécurité incendie", owner_folder="42")
+ tmp_support_slug_cleanup(slug)
+ meta = db.support_meta(slug)
+ assert meta["name"] == "Sécurité incendie"
+ assert meta["published_at"] is None
+
+
+def test_list_supports_scopes_to_owner_and_excludes_games(
+ tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any
+) -> None:
+ owner = "43"
+ support_slug = db.create_support("Onboarding RH", owner_folder=owner)
+ tmp_support_slug_cleanup(support_slug)
+ game_slug = db.create_game("Un jeu du même compte", owner_folder=owner)
+ tmp_game_slug_cleanup(game_slug)
+
+ supports = db.list_supports(owner)
+ games = db.list_games(owner)
+
+ assert [s["slug"] for s in supports] == [support_slug]
+ assert [g["slug"] for g in games] == [game_slug]
+ assert support_slug not in [g["slug"] for g in games]
+ assert game_slug not in [s["slug"] for s in supports]
+
+
+def test_list_supports_never_sees_another_owners_supports(tmp_support_slug_cleanup: Any) -> None:
+ slug_a = db.create_support("Support A", owner_folder="44")
+ tmp_support_slug_cleanup(slug_a)
+ slug_b = db.create_support("Support B", owner_folder="45")
+ tmp_support_slug_cleanup(slug_b)
+
+ assert [s["slug"] for s in db.list_supports("44")] == [slug_a]
+ assert [s["slug"] for s in db.list_supports("45")] == [slug_b]
+
+
+def test_create_support_avoids_name_collision_for_the_same_owner(tmp_support_slug_cleanup: Any) -> None:
+ owner = "46"
+ slug_1 = db.create_support("Module 1", owner_folder=owner)
+ tmp_support_slug_cleanup(slug_1)
+ slug_2 = db.create_support("Module 1", owner_folder=owner)
+ tmp_support_slug_cleanup(slug_2)
+ assert slug_1 != slug_2
+
+
+def test_mark_support_published_persists_a_timestamp(tmp_support_slug_cleanup: Any) -> None:
+ slug = db.create_support("À publier", owner_folder="47")
+ tmp_support_slug_cleanup(slug)
+ assert db.support_meta(slug)["published_at"] is None
+ published_at = db.mark_support_published(slug)
+ assert db.support_meta(slug)["published_at"] == published_at
+
+
+def test_delete_support_removes_it_from_the_listing(tmp_support_slug_cleanup: Any) -> None:
+ slug = db.create_support("Temporaire", owner_folder="48")
+ tmp_support_slug_cleanup(slug)
+ assert [s["slug"] for s in db.list_supports("48")] == [slug]
+ db.delete_support(slug)
+ assert db.list_supports("48") == []
diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py
index 689ea42b..90f5809f 100644
--- a/tests/test_onboarding.py
+++ b/tests/test_onboarding.py
@@ -26,7 +26,11 @@ def test_account_without_a_project_is_redirected_to_onboarding(anon_client: Flas
resp = anon_client.get("/onboarding")
assert resp.status_code == 200
- assert "Créer un jeu 2D ludique" in resp.get_data(as_text=True)
+ # Aucun des deux slots n'est encore rempli — les deux cartes sont
+ # proposées (voir docs/plan/PLAN.md, projet_kind de premier niveau).
+ html = resp.get_data(as_text=True)
+ assert "Environnement de formation 2D" in html
+ assert "Support de formation" in html
finally:
_cleanup_project("needsonboarding@example.com")
@@ -90,24 +94,44 @@ def test_onboarding_rejects_removed_types(anon_client: FlaskClient) -> None: #
_cleanup_project("removedtype@example.com")
-def test_revisiting_onboarding_after_completion_redirects_without_recreating(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
+def test_revisiting_onboarding_after_completing_one_slot_offers_only_the_other(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
+ """Les deux slots (project_slug/support_slug) sont indépendants (voir
+ docs/plan/PLAN.md) : avoir déjà un environnement 2D ne bloque plus
+ /onboarding, mais n'y propose plus que la carte encore libre — tenter
+ de recréer un environnement 2D via un POST direct doit rester rejeté."""
_register(anon_client, "revisit@example.com")
_confirm_2fa(anon_client)
+ support_slug = None
try:
- first = _complete_onboarding(anon_client, name="Premier jeu", onboarding_type="rpg")
+ _complete_onboarding(anon_client, name="Premier jeu", onboarding_type="rpg")
first_slug = not_none(auth.get_user_by_email("revisit@example.com"))["project_slug"]
- resp = anon_client.get("/onboarding", follow_redirects=False)
- assert resp.status_code == 302
- assert resp.headers["Location"] == first.headers["Location"]
+ resp = anon_client.get("/onboarding")
+ assert resp.status_code == 200
+ html = resp.get_data(as_text=True)
+ assert "Environnement de formation 2D" not in html
+ assert "Support de formation" in html
again = anon_client.post(
"/onboarding", data={"onboarding_type": "rpg", "name": "Second jeu"}, follow_redirects=False
)
+ assert again.status_code == 200
assert not_none(auth.get_user_by_email("revisit@example.com"))["project_slug"] == first_slug
- assert again.headers["Location"] == first.headers["Location"]
+
+ second = anon_client.post(
+ "/onboarding", data={"onboarding_type": "support", "name": "Un support"}, follow_redirects=False
+ )
+ assert second.status_code == 302
+ support_slug = not_none(auth.get_user_by_email("revisit@example.com"))["support_slug"]
+ assert second.headers["Location"] == f"/document/{support_slug}/edit"
+
+ both_full = anon_client.get("/onboarding", follow_redirects=False)
+ assert both_full.status_code == 302
+ assert both_full.headers["Location"] == "/"
finally:
_cleanup_project("revisit@example.com")
+ if support_slug:
+ db.delete_support(support_slug)
def test_admin_is_never_redirected_to_onboarding(client: FlaskClient, game: str) -> None:
@@ -132,7 +156,7 @@ def test_admin_can_create_several_games_via_onboarding(client: FlaskClient) -> N
# le premier projet créé, la page de choix s'affiche à nouveau.
resp = client.get("/onboarding")
assert resp.status_code == 200
- assert "Créer un jeu 2D ludique" in resp.get_data(as_text=True)
+ assert "Environnement de formation 2D" in resp.get_data(as_text=True)
resp2 = _complete_onboarding(client, name="Jeu RPG Admin 2", onboarding_type="rpg")
assert resp2.status_code == 302