"""Templates unifiés façon Notion — tests de la refonte (§17.2 du cahier des charges). Couvre : migrations 48/49, backfill idempotent, presets système, résolution des variables (aucun {{token}} survivant), dégradation sur propriété renommée, défaut exclusif, idempotence, récurrence dédupliquée, API interne et annulation (undo). """ import json import pytest from app.db import get_conn from app.services.fd_templates import TemplateService, registry_ready @pytest.fixture def actor(): return {"id": 1, "login": "tester", "full_name": "Tester", "is_admin": 1} def _ensure_ws(conn, actor): """Workspace valide (FK) pour les inserts directs des tests.""" from app.services.fd_templates import _ensure_workspace return _ensure_workspace(conn, actor) def _db_version(): with get_conn() as conn: return conn.execute("SELECT COALESCE(MAX(version),0) FROM schema_version").fetchone()[0] def test_migrations_48_49_applied(client): assert registry_ready(get_conn().__enter__()) or True with get_conn() as conn: assert registry_ready(conn) tables = {r[0] for r in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} assert {"templates", "template_runs", "template_recurrences"} <= tables assert _db_version() >= 49 def test_backfill_idempotent_and_presets(client, actor): with get_conn() as conn: first = TemplateService.backfill_registry(conn) n1 = conn.execute("SELECT COUNT(*) FROM templates WHERE deleted_at IS NULL").fetchone()[0] second = TemplateService.backfill_registry(conn) n2 = conn.execute("SELECT COUNT(*) FROM templates WHERE deleted_at IS NULL").fetchone()[0] assert n1 == n2 assert sum(second.values()) == 0 # Silos legacy catalogués, jamais détruits. legacy_db = conn.execute("SELECT COUNT(*) FROM database_templates").fetchone()[0] reg_db = conn.execute( "SELECT COUNT(*) FROM templates WHERE source_kind='legacy_database'").fetchone()[0] assert reg_db == legacy_db and legacy_db > 0 # Galerie système : au moins 5 presets utilisables. n_presets = TemplateService.ensure_system_presets(conn) sys_rows = conn.execute( "SELECT COUNT(*) FROM templates WHERE scope='system' AND deleted_at IS NULL").fetchone()[0] assert sys_rows >= 5 assert TemplateService.ensure_system_presets(conn) == 0 assert first["legacy_database"] >= 0 def test_instantiate_page_resolves_variables(client, actor): with get_conn() as conn: dto = TemplateService.create_template(conn, actor, { "kind": "page", "name": "Revue", "scope": "personal", "title_template": "Revue du {{today}} — {{me}}", "blocks": [{"id": "b1", "type": "paragraph", "content": "vu le {{today}}"}], }) conn.commit() result = TemplateService.instantiate(conn, actor, dto["id"], {}, {}) assert result["created_type"] == "page" assert "{{" not in result["url"] and result["url"].startswith("/pages/") page = conn.execute("SELECT * FROM pages WHERE id=?", (int(result["created_id"]),)).fetchone() assert "{{" not in (page["title"] or "") assert "{{" not in (page["content"] or "") assert "Revue du 20" in page["title"] # date du jour résolue runs = conn.execute("SELECT * FROM template_runs WHERE template_id=?", (dto["id"],)).fetchall() assert len(runs) == 1 and runs[0]["status"] == "ok" def test_required_variable_blocks_write(client, actor): with get_conn() as conn: dto = TemplateService.create_template(conn, actor, { "kind": "page", "name": "Spec", "scope": "personal", "title_template": "Spec — {{sujet}}", "variables": [{"name": "sujet", "type": "text", "required": True}], "blocks": [], }) conn.commit() before = conn.execute("SELECT COUNT(*) FROM pages").fetchone()[0] with pytest.raises(ValueError, match="sujet"): TemplateService.instantiate(conn, actor, dto["id"], {}, {}) after = conn.execute("SELECT COUNT(*) FROM pages").fetchone()[0] assert before == after # erreur AVANT toute écriture ok = TemplateService.instantiate(conn, actor, dto["id"], {"sujet": "X"}, {}) assert ok["created_type"] == "page" def test_row_template_degraded_property_warning(client, actor): with get_conn() as conn: ws = _ensure_ws(conn, actor) cid = conn.execute( "INSERT INTO collections (name, description, icon, schema_json, workspace_id) VALUES (?,?,?,?,?)", ("Taches", "", "✅", "[]", ws)).lastrowid conn.execute( "INSERT INTO collection_properties (collection_id, name, prop_type, position) VALUES (?,?,?,?)", (cid, "Status", "status", 0)) conn.commit() dto = TemplateService.create_template(conn, actor, { "kind": "row", "name": "Bug", "scope": "personal", "target_collection_id": cid, "manifest": {"property_defaults": {"Status": "À trier", "Priorite": "P1"}}, "blocks": [], }) conn.commit() result = TemplateService.instantiate(conn, actor, dto["id"], {}, {"collection_id": cid}) assert result["created_type"] == "row" # Propriété renommée → avertissement, pas d'échec total. assert any("Priorite" in w for w in result["warnings"]) row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (int(result["created_id"]),)).fetchone() assert row is not None def test_default_exclusive(client, actor): with get_conn() as conn: ws = _ensure_ws(conn, actor) cid = conn.execute( "INSERT INTO collections (name, schema_json, workspace_id) VALUES (?,?,?)", ("B", "[]", ws)).lastrowid conn.commit() a = TemplateService.create_template(conn, actor, { "kind": "row", "name": "A", "scope": "personal", "target_collection_id": cid, "blocks": []}) b = TemplateService.create_template(conn, actor, { "kind": "row", "name": "B", "scope": "personal", "target_collection_id": cid, "blocks": []}) conn.commit() TemplateService.set_default(conn, actor, a["id"]) TemplateService.set_default(conn, actor, b["id"]) conn.commit() rows = conn.execute( "SELECT id FROM templates WHERE target_collection_id=? AND is_default=1 AND deleted_at IS NULL", (cid,)).fetchall() assert [r["id"] for r in rows] == [b["id"]] def test_idempotency_single_object(client, actor): with get_conn() as conn: dto = TemplateService.create_template(conn, actor, { "kind": "page", "name": "Idem", "scope": "personal", "blocks": []}) conn.commit() key = "test-key-123" r1 = TemplateService.instantiate(conn, actor, dto["id"], {}, {}, idempotency_key=key) r2 = TemplateService.instantiate(conn, actor, dto["id"], {}, {}, idempotency_key=key) assert r1["created_id"] == r2["created_id"] assert r2.get("replayed") is True n = conn.execute( "SELECT COUNT(*) FROM pages WHERE title=?", ("Idem",)).fetchone()[0] assert n == 1 def test_recurrence_dedup(client, actor): with get_conn() as conn: ws = _ensure_ws(conn, actor) cid = conn.execute( "INSERT INTO collections (name, schema_json, workspace_id) VALUES (?,?,?)", ("Rec", "[]", ws)).lastrowid conn.commit() dto = TemplateService.create_template(conn, actor, { "kind": "row", "name": "Quotidien", "scope": "personal", "target_collection_id": cid, "blocks": []}) conn.commit() TemplateService.set_recurrence(conn, actor, dto["id"], {"rrule": "FREQ=DAILY", "timezone": ""}) # Prochain passage forcé dans le passé pour le test. conn.execute("UPDATE template_recurrences SET next_run_at='2000-01-01T09:00:00' WHERE template_id=?", (dto["id"],)) conn.commit() fired1 = TemplateService.run_due_recurrences(conn, now_iso="2000-01-02T10:00:00") assert len(fired1) == 1 and fired1[0]["status"] == "ok" # Même occurrence rejouée (redémarrage scheduler) → pas de doublon. conn.execute("UPDATE template_recurrences SET next_run_at='2000-01-01T09:00:00' WHERE template_id=?", (dto["id"],)) conn.commit() fired2 = TemplateService.run_due_recurrences(conn, now_iso="2000-01-02T10:00:00") assert fired2 == [] n = conn.execute("SELECT COUNT(*) FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0] assert n == 1 def test_api_list_instantiate_undo(client): # Liste (le bouton « Templates » consomme cette route). r = client.get("/api/templates?kind=page") assert r.status_code == 200 assert isinstance(r.json()["templates"], list) assert len(r.json()["templates"]) >= 5 # presets système tid = r.json()["templates"][0]["id"] # Détail + aperçu. assert client.get(f"/api/templates/{tid}").status_code == 200 pv = client.post(f"/api/templates/{tid}/preview", json={}) assert pv.status_code == 200 # Instanciation puis annulation (titre fourni pour les variables requises). ins = client.post(f"/api/templates/{tid}/instantiate", json={"variables": {"title": "Test"}}) assert ins.status_code == 200, ins.text body = ins.json() assert body["url"] run_id = body["run_id"] if run_id: undo = client.post(f"/api/templates/runs/{run_id}/undo") assert undo.status_code == 200 # 404 explicite, jamais un no-op silencieux. assert client.get("/api/templates/nope-123").status_code == 404 bad = client.post("/api/templates/nope-123/instantiate", json={}) assert bad.status_code == 404 def test_manager_page_renders(client): r = client.get("/templates") assert r.status_code == 200 assert "Templates" in r.text _VALID_BLOCK_TYPES = { "paragraph", "heading_1", "heading_2", "heading_3", "heading_4", "bulleted_list", "numbered_list", "to_do", "toggle", "quote", "callout", "divider", "code", "table_of_contents", "math", "columns", "table", } def test_system_presets_catalog_is_complete(client, actor): """Galerie : > 20 presets, tous les genres, types de blocs valides.""" from app.services.fd_templates import SYSTEM_PRESETS names = {(p["kind"], p["name"]) for p in SYSTEM_PRESETS} assert len(SYSTEM_PRESETS) >= 25 assert len(names) == len(SYSTEM_PRESETS) # pas de doublon kinds = {p["kind"] for p in SYSTEM_PRESETS} assert {"page", "database", "blocks", "row"} <= kinds # Les blocs utilisent les types réels de l'éditeur (pas heading/todo). for p in SYSTEM_PRESETS: for b in p.get("blocks") or []: assert b["type"] in _VALID_BLOCK_TYPES, (p["name"], b["type"]) with get_conn() as conn: TemplateService.ensure_system_presets(conn) rows = conn.execute( "SELECT kind, COUNT(*) n FROM templates WHERE scope='system' AND deleted_at IS NULL GROUP BY kind" ).fetchall() by_kind = {r["kind"]: r["n"] for r in rows} assert sum(by_kind.values()) >= 25 assert by_kind.get("page", 0) >= 10 assert by_kind.get("database", 0) >= 8 assert by_kind.get("blocks", 0) >= 4 assert by_kind.get("row", 0) >= 4 def test_database_preset_creates_properties(client, actor): with get_conn() as conn: items = TemplateService.list_templates(conn, actor, kind="database") tache = next(t for t in items if t["name"] == "Tâches") res = TemplateService.instantiate(conn, actor, tache["id"], {}, {}) assert res["created_type"] == "collection" props = conn.execute( "SELECT name FROM collection_properties WHERE collection_id=?", (int(res["created_id"]),)).fetchall() names = {p["name"] for p in props} assert {"Statut", "Priorité", "Échéance", "Assigné à"} <= names def test_row_preset_resolves_base_by_name(client, actor): with get_conn() as conn: ws = _ensure_ws(conn, actor) cid = conn.execute( "INSERT INTO collections (name, schema_json, workspace_id) VALUES (?,?,?)", ("Tâches", "[]", ws)).lastrowid conn.commit() items = TemplateService.list_templates(conn, actor, kind="row") tache = next(t for t in items if t["name"] == "Tâche") # Sans collection_id : la base est résolue par nom (manifest). res = TemplateService.instantiate(conn, actor, tache["id"], {}, {}) assert res["created_type"] == "row" row = conn.execute( "SELECT collection_id FROM collection_pages WHERE id=?", (int(res["created_id"]),)).fetchone() assert row["collection_id"] == cid def test_row_preset_without_base_reports_clearly(client, actor): with get_conn() as conn: items = TemplateService.list_templates(conn, actor, kind="row") tache = next(t for t in items if t["name"] == "Tâche") # Aucune base « Tâches » → message explicite, pas d'objet créé. with pytest.raises(ValueError, match="base"): TemplateService.instantiate(conn, actor, tache["id"], {}, {}) def test_page_template_fills_current_page_and_sets_title(client, actor): """Appliquer un template de page SANS remplir de variable ne doit pas échouer (title réservé), et le titre suit le template.""" with get_conn() as conn: ws = _ensure_ws(conn, actor) page_id = conn.execute( "INSERT INTO pages (workspace, workspace_id, title, content, content_format) " "VALUES (?,?,?,?,?)", ("T", ws, "Untitled", "[]", "blocks")).lastrowid conn.commit() items = TemplateService.list_templates(conn, actor, kind="page") tpl = next(t for t in items if t["name"] == "Compte rendu client") res = TemplateService.instantiate( conn, actor, tpl["id"], {}, {"target": {"type": "current_page", "page_id": page_id}, "parent_id": page_id}) conn.commit() assert res["created_type"] == "page" assert res["title"].startswith("CR —") page = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone() assert page["title"] == res["title"] def test_page_template_instantiates_without_variables(client): """Un template de page s'instancie sans variables (title réservé).""" r = client.get("/api/templates?kind=page") for t in r.json()["templates"]: resp = client.post(f"/api/templates/{t['id']}/instantiate", json={"variables": {}}) assert resp.status_code == 200, (t["name"], resp.text) def test_preview_never_400_on_required_vars(client, actor): """L'aperçu ne bloque jamais (tokens montrés tels quels, §7.1).""" with get_conn() as conn: dto = TemplateService.create_template(conn, actor, { "kind": "page", "name": "Spec", "scope": "personal", "title_template": "Spec — {{sujet}}", "variables": [{"name": "sujet", "type": "text", "required": True}], "blocks": [], }) conn.commit() preview = TemplateService.preview(conn, actor, dto["id"], {}) assert "{{sujet}}" in preview["title"] assert any("sujet" in w for w in preview["warnings"]) def test_preview_endpoint_ok_for_system_presets(client): r = client.get("/api/templates?kind=page") assert r.status_code == 200 for t in r.json()["templates"]: pv = client.post(f"/api/templates/{t['id']}/preview", json={}) assert pv.status_code == 200, (t["name"], pv.text) def test_database_template_inline_in_current_page(client, actor): """Un template de base appliqué depuis un document crée une base INLINE rattachée à la page (pas de navigation, embed côté client).""" with get_conn() as conn: ws = _ensure_ws(conn, actor) page_id = conn.execute( "INSERT INTO pages (workspace, workspace_id, title, content, content_format) VALUES (?,?,?,?,?)", ("Test", ws, "Doc", "[]", "blocks")).lastrowid conn.commit() dto = TemplateService.create_template(conn, actor, { "kind": "database", "name": "Projets", "scope": "personal", "manifest": {"collection": {"name": "Projets", "schema": [ {"name": "Status", "type": "status"}]}}, }) conn.commit() result = TemplateService.instantiate( conn, actor, dto["id"], {}, {"parent_page_id": page_id}) assert result["created_type"] == "collection" assert result["url"] == f"/db/{result['created_id']}" col = conn.execute("SELECT * FROM collections WHERE id=?", (int(result["created_id"]),)).fetchone() assert col["parent_page_id"] == page_id assert col["is_inline"] == 1 props = conn.execute( "SELECT name FROM collection_properties WHERE collection_id=?", (col["id"],)).fetchall() assert [p["name"] for p in props] == ["Status"]