Files
flowdeck/tests/test_v54.py
T
bruno cb47f5c7f4
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
fix: A26 + A33 + A34 + A35 + A36 + A43 — bande S/XS du P2 (v7.3.9)
- A26 — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…` ne
  produit plus de chemin UNC sous Windows ; `.env.example` ne promet plus
  PostgreSQL ; raise au boot si `APP_SECRET_KEY` vaut la valeur par défaut
- A33 — rate limit : préfixes manquants (`/scim/v2/`, `/workspace/`, `/db/` +
  non-GET sur `/s/` et `/f/`), limite lue dans `settings.rate_limit_requests`
  (60 annoncés / 100 codés en dur), clé `X-Forwarded-For` seulement derrière un
  proxy local (anti-spoof), `_store` épuré (mémoire bornée) + test dédié
- A34 — `_spawn()` : les 10 schedulers loggent leur exception et redémarrent
  après 10 s au lieu de mourir en silence ; 2 `logger.debug` de scheduler → warning
- A35 — OpenAPI régénéré 439 → 511 chemins (`info.version 7.3.9`), README
  (était v6.7.0), compteur API_GUIDE, titre dupliqué retiré du ROADMAP ;
  le drift Python 3.12/3.13 reste noté (rebuild d'image à valider)
- A36 — 4 dépendances mortes purgées de requirements.txt (aiosqlite, slowapi,
  loguru, packaging = 0 import) ; pyproject reste sans [project] : Docker et la
  CI installent requirements.txt, dupliquer les 22 deps créerait 2 sources
- A43 — 15 `datetime.utcnow()` → `now(UTC).replace(tzinfo=None)` (format ISO
  naïf identique, warnings de dépréciation divisés : 2374 → 926)

suite **1029/1029** · `ruff check app tests` OK · docs/ROADMAP/CHANGELOG/WORKLOAD à jour
2026-10-01 08:50:39 -04:00

576 lines
21 KiB
Python

"""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}&section=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 "<video" in html
assert "<audio" in html
assert "Ex" in html # bookmark title appears in rendered HTML
assert "iframe" in html
class TestDashboardPublicNewBlocks:
"""Public page renderer (_render_blocks_public) for new block types."""
def test_render_blocks_public_video_audio_bookmark_embed(self):
from app.routers.dashboard import _render_blocks_public
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 = _render_blocks_public(blocks)
assert "<video" in html
assert "<audio" in html
assert "bookmark" in html or "Ex" in html
assert "iframe" in html