"""FlowDeck — Tests for v5.4.0 (backlinks, duplicate, trash purge, version history, import) and v5.5.0 (embed, bookmark, video, audio, cover, icon).""" from __future__ import annotations import json import os import tempfile # ── Helpers ────────────────────────────────────────────────────────────── def _login(client): """Create admin user and return session cookie + csrf token (like test_app.py).""" import secrets from app.auth.session import SessionManager from app.db import get_conn with get_conn() as conn: login = f"testadmin_{secrets.token_hex(4)}" conn.execute( "INSERT INTO users (login, full_name, email, password_hash, is_admin) " "VALUES (?, 'Test 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}) # get CSRF token r2 = client.get("/api/csrf-token", cookies={"flowdeck_session": session}) csrf = r2.json()["csrf_token"] return session, csrf def _create_workspace(client, cookie, name="Test WS"): r = client.post("/workspace", json={"name": name}, cookies={"flowdeck_session": cookie}) assert r.status_code == 200 ws = r.json() client.cookies.set("flowdeck_workspace", str(ws["id"])) return ws["id"] def _create_page(client, cookie, csrf, title="Test Page", content="", parent_id=0): r = client.post( f"/board/api/pages?title={title}§ion=Private&parent_id={parent_id}", headers={"X-CSRF-Token": csrf}, cookies={"flowdeck_session": cookie}, ) assert r.status_code == 200 return r.json()["id"] def _save_blocks(client, cookie, csrf, page_id, blocks, title="Test Page"): r = client.post( f"/board/api/pages/{page_id}/blocks", json={"title": title, "blocks": blocks}, headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, cookies={"flowdeck_session": cookie}, ) assert r.status_code == 200 return r.json() # ── Tests ──────────────────────────────────────────────────────────────── class TestV54Backlinks: """v5.4.0: backlinks endpoint.""" def test_backlinks_detects_internal_links(self, client): cookie, csrf = _login(client) _create_workspace(client, cookie) # page A page_a = _create_page(client, cookie, csrf, "Page A") # page B links to page A via markdown-style internal link blocks = [{"type": "paragraph", "content": f"See [Page A](/pages/{page_a}) for details"}] _save_blocks(client, cookie, csrf, page_a, blocks, "Page A") # page C also links to page A page_c = _create_page(client, cookie, csrf, "Page C") blocks_c = [{"type": "paragraph", "content": f"Ref: /pages/{page_a}"}] _save_blocks(client, cookie, csrf, page_c, blocks_c, "Page C") # fetch backlinks r = client.get( f"/board/api/pages/{page_a}/backlinks", cookies={"flowdeck_session": cookie}, ) assert r.status_code == 200 data = r.json() assert "backlinks" in data ids = [b["id"] for b in data["backlinks"]] assert page_c in ids class TestV54Duplicate: """v5.4.0: page duplication (deep copy of content).""" def test_duplicate_page_copies_blocks(self, client): cookie, csrf = _login(client) _create_workspace(client, cookie) page = _create_page(client, cookie, csrf, "Original") blocks = [ {"type": "heading_1", "content": "Title"}, {"type": "paragraph", "content": "Body text"}, {"type": "bulleted_list", "content": "Item 1"}, ] _save_blocks(client, cookie, csrf, page, blocks, "Original") # duplicate r = client.post( f"/board/api/pages/{page}/duplicate", headers={"X-CSRF-Token": csrf}, cookies={"flowdeck_session": cookie}, ) assert r.status_code == 200 dup = r.json() assert dup["status"] == "ok" new_id = dup["id"] # verify content copied r2 = client.get( f"/board/api/pages/{new_id}", cookies={"flowdeck_session": cookie}, ) assert r2.status_code == 200 new_page = r2.json() assert new_page["content_format"] == "blocks" new_blocks = json.loads(new_page["content"]) assert len(new_blocks) == 3 assert new_blocks[0]["type"] == "heading_1" assert new_blocks[1]["content"] == "Body text" class TestV54VersionHistory: """v5.4.0: version snapshots + list + restore.""" def test_version_snapshot_created_on_save(self, client): cookie, csrf = _login(client) _create_workspace(client, cookie) page = _create_page(client, cookie, csrf, "Versioned") blocks_v1 = [{"type": "paragraph", "content": "Version 1"}] _save_blocks(client, cookie, csrf, page, blocks_v1, "Versioned") # list versions r = client.get( f"/board/api/pages/{page}/versions", cookies={"flowdeck_session": cookie}, ) assert r.status_code == 200 data = r.json() assert len(data["versions"]) >= 1 v1_id = data["versions"][0]["id"] # modify and save again blocks_v2 = [{"type": "paragraph", "content": "Version 2"}] _save_blocks(client, cookie, csrf, page, blocks_v2, "Versioned") # list again r2 = client.get( f"/board/api/pages/{page}/versions", cookies={"flowdeck_session": cookie}, ) data2 = r2.json() assert len(data2["versions"]) >= 2 data2["versions"][0]["id"] # restore v1 r3 = client.post( f"/board/api/pages/{page}/versions/{v1_id}/restore", headers={"X-CSRF-Token": csrf}, cookies={"flowdeck_session": cookie}, ) assert r3.status_code == 200 # verify content is back to v1 r4 = client.get( f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie}, ) restored = r4.json() restored_blocks = json.loads(restored["content"]) assert restored_blocks[0]["content"] == "Version 1" class TestV54TrashPurge: """v5.4.0: global trash 30-day purge.""" def test_trash_purge_removes_old_deleted_pages(self, client): import datetime from app.db import get_conn, init_db from app.services.trash import purge_expired # create temp DB tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) tmp.close() os.environ["DATABASE_URL"] = f"sqlite:///{tmp.name}" os.environ["APP_SECRET_KEY"] = "test-secret" os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["BACKUP_ENABLED"] = "false" os.environ["PROJECT_SYNC_ENABLED"] = "false" import app.config # In-place mutation, jamais de rebinding : les modules importés plus tôt # (sso_provisioning, trash, …) gardent une référence à l'objet courant. _s = app.config.settings _s.database_url = f"sqlite:///{tmp.name}" _s.app_secret_key = "test-secret" _s.rate_limit_enabled = False _s.backup_enabled = False _s.project_sync_enabled = False init_db() with get_conn() as conn: # insert 2 deleted pages: one old, one recent old = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(days=45) recent = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - datetime.timedelta(days=5) cur = conn.execute( "INSERT INTO pages (workspace, title, deleted_at) VALUES (?,?,?)", ("test", "Old Page", old.isoformat()), ) old_id = cur.lastrowid cur = conn.execute( "INSERT INTO pages (workspace, title, deleted_at) VALUES (?,?,?)", ("test", "Recent Page", recent.isoformat()), ) recent_id = cur.lastrowid conn.commit() # run purge (30 days) result = purge_expired(days=30) assert result["count"] == 1 assert old_id in result["purged"] with get_conn() as conn: row = conn.execute("SELECT id FROM pages WHERE id=?", (old_id,)).fetchone() assert row is None row = conn.execute("SELECT id FROM pages WHERE id=?", (recent_id,)).fetchone() assert row is not None os.unlink(tmp.name) class TestV54Import: """v5.4.0: markdown / file import.""" def test_import_markdown_creates_page(self, client): cookie, csrf = _login(client) _create_workspace(client, cookie) md = "# Imported\n\nContent here\n\n- item 1\n- item 2" r = client.post( "/board/api/pages/import", json={"markdown": md, "title": "Imported Page"}, headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, cookies={"flowdeck_session": cookie}, ) assert r.status_code == 200 data = r.json() assert data["status"] == "ok" assert "id" in data # verify page content r2 = client.get( f"/board/api/pages/{data['id']}", cookies={"flowdeck_session": cookie}, ) page = r2.json() blocks = json.loads(page["content"]) assert any(b["type"] == "heading_1" and b["content"] == "Imported" for b in blocks) assert any(b["type"] == "bulleted_list" and "item 1" in b["content"] for b in blocks) class TestV55CoverIcon: """v5.5.0: page cover & icon.""" def test_set_cover_url(self, client): cookie, csrf = _login(client) _create_workspace(client, cookie) page = _create_page(client, cookie, csrf, "With Cover") r = client.post( f"/board/api/pages/{page}/cover", json={"cover_url": "https://example.com/cover.png"}, headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, cookies={"flowdeck_session": cookie}, ) assert r.status_code == 200 data = r.json() assert data["cover_url"] == "https://example.com/cover.png" # verify persisted r2 = client.get( f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie}, ) assert r2.json().get("cover_url") == "https://example.com/cover.png" def test_set_page_icon(self, client): cookie, csrf = _login(client) _create_workspace(client, cookie) page = _create_page(client, cookie, csrf, "With Icon") r = client.post( f"/board/api/pages/{page}/icon", json={"icon": "🚀"}, headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, cookies={"flowdeck_session": cookie}, ) assert r.status_code == 200 data = r.json() assert data["icon"] == "🚀" r2 = client.get( f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie}, ) assert r2.json().get("page_icon") == "🚀" class TestV55EmbedBookmark: """v5.5.0: embed, bookmark, video, audio blocks (smoke tests).""" def test_embed_block_render(self, client): cookie, csrf = _login(client) _create_workspace(client, cookie) page = _create_page(client, cookie, csrf, "Embed Test") blocks = [ {"type": "embed", "src": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "embed_type": "", "height": 520} ] _save_blocks(client, cookie, csrf, page, blocks, "Embed Test") r = client.get( f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie}, ) saved = r.json() saved_blocks = json.loads(saved["content"]) assert saved_blocks[0]["type"] == "embed" assert "youtube.com" in saved_blocks[0]["src"] def test_bookmark_block_render(self, client): cookie, csrf = _login(client) _create_workspace(client, cookie) page = _create_page(client, cookie, csrf, "Bookmark Test") blocks = [ {"type": "bookmark", "url": "https://example.com", "title": "Example", "description": "A test site"} ] _save_blocks(client, cookie, csrf, page, blocks, "Bookmark Test") r = client.get( f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie}, ) saved = r.json() saved_blocks = json.loads(saved["content"]) assert saved_blocks[0]["type"] == "bookmark" assert saved_blocks[0]["url"] == "https://example.com" def test_video_block_render(self, client): cookie, csrf = _login(client) _create_workspace(client, cookie) page = _create_page(client, cookie, csrf, "Video Test") blocks = [{"type": "video", "src": "https://example.com/video.mp4"}] _save_blocks(client, cookie, csrf, page, blocks, "Video Test") r = client.get( f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie}, ) saved = r.json() saved_blocks = json.loads(saved["content"]) assert saved_blocks[0]["type"] == "video" assert saved_blocks[0]["src"] == "https://example.com/video.mp4" def test_audio_block_render(self, client): cookie, csrf = _login(client) _create_workspace(client, cookie) page = _create_page(client, cookie, csrf, "Audio Test") blocks = [{"type": "audio", "src": "https://example.com/audio.mp3"}] _save_blocks(client, cookie, csrf, page, blocks, "Audio Test") r = client.get( f"/board/api/pages/{page}", cookies={"flowdeck_session": cookie}, ) saved = r.json() saved_blocks = json.loads(saved["content"]) assert saved_blocks[0]["type"] == "audio" assert saved_blocks[0]["src"] == "https://example.com/audio.mp3" class TestV55PublicPageCoverIcon: """v5.5.0: public page includes cover_url and page_icon.""" def test_published_page_includes_cover_icon(self, client): cookie, csrf = _login(client) _create_workspace(client, cookie) page = _create_page(client, cookie, csrf, "Public Page") _save_blocks(client, cookie, csrf, page, [{"type": "paragraph", "content": "Hello"}], "Public Page") # set cover + icon client.post( f"/board/api/pages/{page}/cover", json={"cover_url": "https://example.com/cover.jpg"}, headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, cookies={"flowdeck_session": cookie}, ) client.post( f"/board/api/pages/{page}/icon", json={"icon": "🌟"}, headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, cookies={"flowdeck_session": cookie}, ) # publish (via publish endpoint) r = client.post( f"/board/api/pages/{page}/publish", headers={"X-CSRF-Token": csrf}, cookies={"flowdeck_session": cookie}, ) assert r.status_code == 200 slug = r.json()["publish_slug"] # fetch public page r2 = client.get(f"/p/{slug}") assert r2.status_code == 200 html = r2.text assert "cover.jpg" in html assert "🌟" in html class TestV54CollectionDuplicate: """v5.4.0: duplicate collection (database).""" def test_duplicate_collection_copies_structure(self, client): cookie, csrf = _login(client) _create_workspace(client, cookie) # create source collection r = client.post( "/db/api", json={"name": "Source DB", "icon": "📋"}, headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, cookies={"flowdeck_session": cookie}, ) assert r.status_code == 200 src_id = r.json()["id"] # add a property r = client.post( f"/db/{src_id}/properties/api", json={"name": "Status", "prop_type": "select", "options": ["todo", "done"]}, headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, cookies={"flowdeck_session": cookie}, ) assert r.status_code == 200 prop_id = r.json()["id"] # add a row r = client.post( f"/db/{src_id}/pages/api", json={"title": "Row 1", "properties": {str(prop_id): "todo"}}, headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, cookies={"flowdeck_session": cookie}, ) assert r.status_code == 200 # duplicate r = client.post( f"/db/{src_id}/duplicate", headers={"X-CSRF-Token": csrf}, cookies={"flowdeck_session": cookie}, ) assert r.status_code == 200 dup = r.json() assert dup["status"] == "duplicated" new_id = dup["id"] # verify properties copied r2 = client.get( f"/db/{new_id}/properties/api", cookies={"flowdeck_session": cookie}, ) assert r2.status_code == 200 props = r2.json()["properties"] assert any(p["name"] == "Status" for p in props) # verify pages copied r3 = client.get( f"/api/collections/{new_id}/table-data", cookies={"flowdeck_session": cookie}, ) assert r3.status_code == 200 pages = r3.json()["pages"] assert len(pages) == 1 assert pages[0]["title"] == "Row 1" class TestV55OGMetadata: """v5.5.0: Open Graph metadata endpoint.""" def test_og_metadata_endpoint_exists(self, client): cookie, csrf = _login(client) r = client.post( "/board/api/og/metadata", json={"url": "https://example.com"}, headers={"X-CSRF-Token": csrf, "Content-Type": "application/json"}, cookies={"flowdeck_session": cookie}, ) # endpoint exists, might return ok or error depending on network assert r.status_code in (200, 400, 500, 502, 503, 504) class TestExportNewBlocks: """Export service renders new block types.""" def test_blocks_to_markdown_includes_new_types(self): from app.services.export import blocks_to_markdown blocks = [ {"type": "video", "src": "https://ex.com/v.mp4"}, {"type": "audio", "src": "https://ex.com/a.mp3"}, {"type": "bookmark", "url": "https://ex.com", "title": "Ex"}, {"type": "embed", "src": "https://youtube.com/watch?v=abc", "embed_type": ""}, ] md = blocks_to_markdown(blocks) assert "[Video]" in md assert "[Audio]" in md assert "[Ex](https://ex.com)" in md or "https://ex.com" in md assert "https://youtube.com/watch?v=abc" in md def test_blocks_to_html_includes_new_types(self): from app.services.export import blocks_to_html blocks = [ {"type": "video", "src": "https://ex.com/v.mp4"}, {"type": "audio", "src": "https://ex.com/a.mp3"}, {"type": "bookmark", "url": "https://ex.com", "title": "Ex", "description": "Desc", "image": "img.jpg", "site_name": "Example"}, {"type": "embed", "src": "https://youtube.com/watch?v=abc", "embed_type": "", "height": 400}, ] html = blocks_to_html(blocks) assert "