Files
flowdeck/tests/test_v511_v512_wiki_templates.py
bruno ee1d46e965
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
fix: A27 phase 2a — éditeur 2 516 L extrait via page-data JSON (v7.22.0)
- `_page_editor_scripts.html` : le gros bloc interpolé (2 516 L) part vers
  `static/js/page_editor_scripts.js` — recette « config JSON » : les 8
  interpolations Jinja lisent `PD = JSON.parse(#page-data)`, bloc JSON qui
  EXISTAIT DÉJÀ juste avant le script (même ordre d'exécution), garde
  `__fdEditorScriptsLoaded` préservée, node --check vert.
- Route `view_page_root` : page_data enrichi de updated_at, created_at,
  user_id, is_shared (dérivé HOISTÉ : une seule expression sert le ctx ET le
  JSON) et clip_icon (macro fd_icon rendue côté serveur). workspace_key reste
  vide comme avant (jamais défini dans ce ctx → parité stricte).

8 tests adaptés à l'extraction (ils lisaient le template SOURCE) :
- test_ai_writing ×2 (+ helper _read_js), test_pwa_offline,
  test_v511 front_end_wired, test_v55 ×3 → lisent le JS extrait
- test_page_editor_renders_page_is_shared → parsing du JSON #page-data
  (`is_shared is True`) — la valeur sert toujours à la page

Cumul A27 : 6 759 L extraites (13 904 → 7 145 inline). Reste : local_workspace
2 031, base 1 523 (structurel {% for %}/{% block %}), database_table 1 323,
settings 1 093, realtime 531, board 146 ≈ 6 653 L + 120 warnings eslint.

suite **1089/1089** · ruff OK · docs à jour
2026-10-01 16:12:26 -04:00

403 lines
18 KiB
Python

"""FlowDeck — v5.11.0 Wiki-links & mentions + v5.12.0 Templates & page lock.
Covers: migrations 13, the wiki token helpers, the picker / titles endpoints,
rename propagation, backlinks via tokens, the page lock (423 guard, unlock
permissions), page layout options, global page templates (list/create/use)
and the public renderer wiki chips.
"""
from __future__ import annotations
import json
import os
import secrets
import tempfile
import pytest
from conftest import login_test_client
from fastapi.testclient import TestClient
@pytest.fixture
def client():
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
db_path = db_file.name
db_file.close()
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false"
os.environ["PUBLIC_API_INSECURE_OK"] = "true"
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
settings.rate_limit_enabled = False
settings.public_api_insecure_ok = True
from app.db import init_db
from app.main import app
init_db()
yield login_test_client(TestClient(app))
try:
os.unlink(db_path)
except PermissionError:
pass
def _login(client):
from app.auth.session import SessionManager
from app.db import get_conn
with get_conn() as conn:
login = f"v511u_{secrets.token_hex(4)}"
conn.execute(
"INSERT INTO users (login, full_name, email, password_hash, is_admin) "
"VALUES (?, 'V511 User', ?, ?, 0)",
(login, f"{login}@test.com", ""),
)
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit()
session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 0})
# Réutilise le CSRF du jar : /api/csrf-token fait tourner le token à chaque
# appel, un token rendu par un login précédent deviendrait invalide (403).
csrf = client.cookies.get("csrf_token")
if not csrf:
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
return session, csrf, uid
def _login_admin(client):
from app.auth.session import SessionManager
from app.db import get_conn
with get_conn() as conn:
login = f"v511a_{secrets.token_hex(4)}"
conn.execute(
"INSERT INTO users (login, full_name, email, password_hash, is_admin) "
"VALUES (?, 'V511 Admin', ?, ?, 1)",
(login, f"{login}@test.com", ""),
)
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit()
session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 1})
# Réutilise le CSRF du jar : /api/csrf-token fait tourner le token à chaque
# appel, un token rendu par un login précédent deviendrait invalide (403).
csrf = client.cookies.get("csrf_token")
if not csrf:
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
return session, csrf, uid
def _mk_page(client, session, title, blocks=None):
r = client.post("/board/api/pages", params={"title": title, "section": "Private"},
cookies={"flowdeck_session": session})
assert r.status_code == 200
pid = r.json()["id"]
if blocks is not None:
r = client.post(f"/board/api/pages/{pid}/blocks",
json={"title": title, "blocks": blocks},
cookies={"flowdeck_session": session})
assert r.status_code == 200, r.text
return pid
def _blocks_b(text):
return [{"id": "b1", "type": "paragraph", "content": text}]
# ── Migration ──
def test_migration_v513_schema(client):
from app.db import get_conn
with get_conn() as conn:
pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
for col in ("is_locked", "locked_by", "full_width", "font_small"):
assert col in pcols
assert "page_global_templates" in tables
assert version >= 13
# ── wiki_links service ──
def test_wiki_token_helpers():
from app.services.wiki_links import extract_page_ids, resolve_tokens_html
assert extract_page_ids("see [[fdpage:12]] and [[fdpage:7]] plus [[fdpage:12]]") == [7, 12]
html = resolve_tokens_html(
"Hi [[fdpage:7]] on [[fddate:2026-10-01]]",
{"[[fdpage:7]]": "Project X", "[[fddate:2026-10-01]]": "Thu 1 Oct 2026"},
)
assert '<a class="fd-wiki-link" href="/pages/7"' in html and "Project X" in html
assert 'class="fd-wiki-date"' in html and "Thu 1 Oct 2026" in html
# escaping: raw HTML in label is escaped
html2 = resolve_tokens_html("[[fdpage:1]]", {"[[fdpage:1]]": "<script>x</script>"})
assert "<script>" not in html2 and "&lt;script&gt;" in html2
# ── Picker + titles endpoints ──
def test_wiki_pages_picker(client):
s, c, uid = _login(client)
_mk_page(client, s, "Project Aurora")
_mk_page(client, s, "Meeting notes")
r = client.get("/board/api/wiki/pages", params={"q": "aurora"},
cookies={"flowdeck_session": s})
assert r.status_code == 200
titles = [p["title"] for p in r.json()["pages"]]
assert "Project Aurora" in titles and "Meeting notes" not in titles
# subsequence fuzzy: "mtg" matches "Meeting notes"? m-t-g not in order → try "mnt"
r2 = client.get("/board/api/wiki/pages", params={"q": "mnt"},
cookies={"flowdeck_session": s}).json()["pages"]
assert any(p["title"] == "Meeting notes" for p in r2)
def test_wiki_titles_rename_propagation(client):
s, c, uid = _login(client)
target = _mk_page(client, s, "Old Title")
r = client.get("/board/api/wiki/titles", params={"ids": str(target)},
cookies={"flowdeck_session": s})
assert r.json()["titles"][str(target)].strip().endswith("Old Title")
# rename
client.put(f"/board/api/pages/{target}", params={"title": "New Title"},
cookies={"flowdeck_session": s})
r2 = client.get("/board/api/wiki/titles", params={"ids": f"{target},99999"},
cookies={"flowdeck_session": s})
assert "New Title" in r2.json()["titles"][str(target)]
assert r2.json()["titles"].get("99999") is None # unknown ids simply absent
# ── Tokens persist + backlinks ──
def test_wiki_token_in_blocks_and_backlink(client):
s, c, uid = _login(client)
target = _mk_page(client, s, "Linked Page")
src = _mk_page(client, s, "Source", _blocks_b(f"Check [[fdpage:{target}]] for details"))
# token survives the save
page = client.get(f"/board/api/pages/{src}").json()
assert f"[[fdpage:{target}]]" in page["content"]
# backlink scanner finds the token reference
bl = client.get(f"/board/api/pages/{target}/backlinks",
cookies={"flowdeck_session": s}).json()["backlinks"]
assert any(b["id"] == src for b in bl)
def test_public_page_renders_wiki_chips(client):
s, c, uid = _login(client)
target = _mk_page(client, s, "Target Doc")
pid = _mk_page(client, s, "Public Doc", _blocks_b(f"Hello [[fdpage:{target}]] today [[fddate:2026-12-25]]"))
r = client.post(f"/board/api/pages/{pid}/publish", cookies={"flowdeck_session": s})
assert r.status_code == 200, r.text
slug = r.json()["publish_slug"]
html = client.get(f"/p/{slug}").text
assert f'href="/pages/{target}"' in html
assert "Target Doc" in html
assert "fd-wiki-date" in html and "Fri 25 Dec 2026" in html
# ── Page lock ──
def test_page_lock_blocks_non_owner(client):
owner_s, owner_c, owner_uid = _login(client)
other_s, other_c, other_uid = _login(client)
pid = _mk_page(client, owner_s, "Locked", _blocks_b("keep me"))
r = client.post(f"/board/api/pages/{pid}/lock", json={"locked": True},
cookies={"flowdeck_session": owner_s}, headers={"X-CSRF-Token": owner_c})
assert r.status_code == 200 and r.json()["is_locked"] == 1
# non-owner edit → 423
r2 = client.post(f"/board/api/pages/{pid}/blocks",
json={"title": "hacked", "blocks": _blocks_b("hacked")},
cookies={"flowdeck_session": other_s})
assert r2.status_code == 423
# the owner can still edit
r3 = client.post(f"/board/api/pages/{pid}/blocks",
json={"title": "Locked", "blocks": _blocks_b("still fine")},
cookies={"flowdeck_session": owner_s})
assert r3.status_code == 200
# non-owner cannot unlock
r4 = client.post(f"/board/api/pages/{pid}/lock", json={"locked": False},
cookies={"flowdeck_session": other_s}, headers={"X-CSRF-Token": other_c})
assert r4.status_code == 403
# admin can force-unlock
a_s, a_c, a_uid = _login_admin(client)
r5 = client.post(f"/board/api/pages/{pid}/lock", json={"locked": False},
cookies={"flowdeck_session": a_s}, headers={"X-CSRF-Token": a_c})
assert r5.status_code == 200 and r5.json()["is_locked"] == 0
# after unlock, everyone edits again
r6 = client.post(f"/board/api/pages/{pid}/blocks",
json={"title": "Locked", "blocks": _blocks_b("open again")},
cookies={"flowdeck_session": other_s})
assert r6.status_code == 200
def test_page_lock_put_and_get_page_data(client):
s, c, uid = _login(client)
pid = _mk_page(client, s, "QL", _blocks_b("x"))
r = client.put(f"/board/api/pages/{pid}", params={"title": "T"},
cookies={"flowdeck_session": s})
assert r.status_code == 200
client.post(f"/board/api/pages/{pid}/lock", json={"locked": True},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
other_s, other_c, _ = _login(client)
r2 = client.put(f"/board/api/pages/{pid}", params={"title": "nope"},
cookies={"flowdeck_session": other_s})
assert r2.status_code == 423
# rendered page context carries lock state
html = client.get(f"/pages/{pid}", cookies={"flowdeck_session": other_s}).text
assert '"is_locked": true' in html and '"can_edit": false' in html
def test_page_options(client):
s, c, uid = _login(client)
pid = _mk_page(client, s, "Opt")
r = client.post(f"/board/api/pages/{pid}/options",
json={"full_width": True, "font_small": False},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r.status_code == 200 and r.json()["full_width"] is True
html = client.get(f"/pages/{pid}", cookies={"flowdeck_session": s}).text
assert '"full_width": true' in html
r2 = client.post(f"/board/api/pages/{pid}/options", json={},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r2.status_code == 400
# ── Global page templates ──
def test_builtin_templates_seeded_in_api(client):
s, c, uid = _login(client)
r = client.get("/board/api/page-templates", cookies={"flowdeck_session": s})
assert r.status_code == 200
tpls = r.json()["templates"]
names = [t["name"] for t in tpls]
for expected in ("Empty", "Meeting notes", "Weekly report", "To-do list", "Project doc"):
assert expected in names
assert all(t["builtin"] for t in tpls)
def test_use_builtin_template_creates_page(client):
s, c, uid = _login(client)
r = client.post("/board/api/page-templates/0/use", json={"key": "meeting_notes"},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r.status_code == 200
pid = r.json()["id"]
page = client.get(f"/board/api/pages/{pid}").json()
assert page["content_format"] == "blocks"
blocks = json.loads(page["content"])
types = [b["type"] for b in blocks]
assert "heading_1" in types and "to_do" in types
texts = " ".join(b.get("content", "") for b in blocks)
assert "Action items" in texts
# unknown key → 404
assert client.post("/board/api/page-templates/0/use", json={"key": "nope"},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c}).status_code == 404
def test_use_builtin_template_assigns_block_ids(client):
"""Template blocks must be persisted with unique ids (v5.13.1).
Without ids the realtime room and the editor disagree on block identity,
which duplicated/shuffled lines when editing a template page.
"""
s, c, uid = _login(client)
r = client.post("/board/api/page-templates/0/use", json={"key": "weekly_report"},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r.status_code == 200
page = client.get(f"/board/api/pages/{r.json()['id']}").json()
blocks = json.loads(page["content"])
ids = [b.get("id") for b in blocks]
assert all(ids), f"missing ids: {ids}"
assert len(set(ids)) == len(ids), f"duplicate ids: {ids}"
def test_use_template_persists_workspace_id(client):
s, c, uid = _login(client)
# Create a local workspace owned by this user
r = client.post("/api/workspaces", json={"name": "Tpl WS"},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r.status_code == 200
ws_id = r.json()["id"]
client.post(f"/api/workspaces/{ws_id}/select", cookies={"flowdeck_session": s})
# built-in template use with workspace context
r2 = client.post("/board/api/page-templates/0/use", json={"key": "meeting_notes", "workspace_id": ws_id},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r2.status_code == 200
pid = r2.json()["id"]
page = client.get(f"/board/api/pages/{pid}").json()
assert page["workspace_id"] == ws_id
assert page["workspace"] == "Tpl WS"
# user template use with workspace context
r3 = client.post("/board/api/page-templates",
json={"name": "Tpl WS template", "blocks": _blocks_b("ws body")},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
tid = r3.json()["id"]
r4 = client.post(f"/board/api/page-templates/{tid}/use", json={"workspace_id": ws_id},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r4.status_code == 200
page2 = client.get(f"/board/api/pages/{r4.json()['id']}").json()
assert page2["workspace_id"] == ws_id
assert page2["workspace"] == "Tpl WS"
# routing: template-created page must appear in the workspace tree
tree = client.get("/api/local-workspace/tree", cookies={"flowdeck_session": s}).json()
ids = [n["id"] for n in tree["tree"]]
assert pid in ids
def test_user_template_roundtrip(client):
s, c, uid = _login(client)
pid = _mk_page(client, s, "My Weekly", _blocks_b("body text"))
r = client.post("/board/api/page-templates",
json={"name": "My Weekly template", "page_id": pid, "icon": "⭐"},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r.status_code == 200
tid = r.json()["id"]
lst = client.get("/board/api/page-templates", cookies={"flowdeck_session": s}).json()["templates"]
mine = [t for t in lst if t["name"] == "My Weekly template"]
assert mine and mine[0]["builtin"] is False and mine[0]["icon"] == "⭐"
# instantiate
r2 = client.post(f"/board/api/page-templates/{tid}/use", json={"title": "New instance"},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r2.status_code == 200 and r2.json()["title"] == "New instance"
npage = client.get(f"/board/api/pages/{r2.json()['id']}").json()
assert "body text" in npage["content"]
# templates are personal: another user can neither list nor use it
s2, c2, _ = _login(client)
lst2 = client.get("/board/api/page-templates", cookies={"flowdeck_session": s2}).json()["templates"]
assert all(t["name"] != "My Weekly template" for t in lst2)
assert client.post(f"/board/api/page-templates/{tid}/use", cookies={"flowdeck_session": s2},
headers={"X-CSRF-Token": c2}).status_code == 404
def test_template_requires_name(client):
s, c, uid = _login(client)
r = client.post("/board/api/page-templates", json={"blocks": _blocks_b("x")},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r.status_code == 400
def test_block_templates_service_shapes():
from app.services.block_templates import blocks_json_for, template_list
assert blocks_json_for("empty") is not None
assert blocks_json_for("nope") is None
meeting = json.loads(blocks_json_for("meeting_notes"))
assert any(b["type"] == "heading_2" and b["content"] == "Agenda" for b in meeting)
assert any(b["type"] == "to_do" for b in meeting)
for t in template_list():
assert set(t) >= {"key", "name", "icon", "description", "builtin"}
# ── Front-end wiring (rendered template markers) ──
def test_editor_front_end_wired(client):
import pathlib
src = pathlib.Path("static/js/page_editor_scripts.js").read_text(encoding="utf-8")
base = pathlib.Path("app/templates/base.html").read_text(encoding="utf-8")
assert "gtTok" in src and "fd-wiki-chip" in src # chip render + reader
assert "wiki/pages" in src and "wiki/titles" in src # picker + label resolution
assert "WM={" in src and "fdpage:" in src # [[ picker module
assert "_openTemplatePicker" in base and "page-templates" in base
assert "toggleLock" in src and "saveAsTemplate" in src
css = pathlib.Path("static/css/app.css").read_text(encoding="utf-8")
assert ".fd-wiki-chip" in css and ".fd-lock-banner" in css and ".tpl-picker-modal" in css