Files
flowdeck/tests/test_v65_synced_db.py
bruno 5951c707eb
FlowDeck CI / lint (push) Successful in 1m27s
FlowDeck CI / test (push) Successful in 10m51s
FlowDeck CI / docker (push) Successful in 1m21s
feat: v6.5.0 Synced blocks production — pages contenu par lignes de DB, résolution serveur à chaque lecture, propagation écrite réelle
2026-09-24 08:28:25 -04:00

492 lines
18 KiB
Python

"""FlowDeck — v6.5.0 Synced blocks production (databases & vues).
Covers:
- migration 22 (``pages.collection_row_id``) + index;
- database row content pages: lazy creation, idempotence, cascade delete,
title sync row ↔ page;
- sidebar/tree/workspace listings exclude content pages;
- server-side resolution of synced blocks on every read path
(content API, public page) — stale cache is replaced at read time;
- propagation: updating/deleting a source REWRITES every referencing
page's stored content (was dead code), recursively (columns/toggles),
including content pages of database rows;
- API v2 exposes ``content_page_id`` for rows.
"""
from __future__ import annotations
import json
import secrets
from app.db import get_conn
from app.services.synced_blocks import (
create_synced_block,
get_page_synced,
get_synced_block,
update_synced_block,
)
# ── Helpers (patterns from test_v514 / test_v57) ─────────────────
def _login(client):
from app.auth.session import SessionManager
login = f"v65_{secrets.token_hex(4)}"
with get_conn() as conn:
conn.execute(
"INSERT INTO users (login, full_name, email, is_admin) "
"VALUES (?, 'V65', ?, 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})
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
return session, csrf, uid
def _hdr(csrf):
return {"X-CSRF-Token": csrf}
def _create_page(client, session, csrf, title="Page"):
r = client.post(
f"/board/api/pages?title={title}",
headers=_hdr(csrf),
cookies={"flowdeck_session": session},
)
assert r.status_code == 200, r.text
return r.json()["id"]
def _add_collection(client, name="DB"):
r = client.post("/db/api", json={"name": name})
assert r.status_code == 200, r.text
return r.json()["id"]
def _add_row(client, cid, title="Row A"):
r = client.post(f"/db/{cid}/pages/api", json={"title": title})
assert r.status_code == 200, r.text
return r.json()["id"]
def _raw_page_content(page_id):
with get_conn() as conn:
row = conn.execute(
"SELECT content, content_format, title, parent_section, "
"collection_row_id, deleted_at FROM pages WHERE id=?",
(page_id,),
).fetchone()
return dict(row) if row else None
# ── Migration ────────────────────────────────────────────────────
def test_migration_v65_column_and_index(client):
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
idx = {r[1] for r in conn.execute("PRAGMA index_list(pages)").fetchall()}
version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
assert "collection_row_id" in cols
assert "idx_pages_row" in idx
assert version >= 22
# ── Row content pages ────────────────────────────────────────────
def test_open_row_creates_content_page(client):
session, csrf, uid = _login(client)
cid = _add_collection(client)
rid = _add_row(client, cid, "My row")
r1 = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session})
assert r1.status_code == 200, r1.text
pid1 = r1.json()["page_id"]
assert pid1 > 0
page = _raw_page_content(pid1)
assert page is not None
assert page["collection_row_id"] == rid
assert page["title"] == "My row"
assert page["parent_section"] == "DbRow"
assert page["content_format"] == "blocks"
# Idempotent: second open returns the same page.
r2 = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session})
assert r2.json()["page_id"] == pid1
def test_open_unknown_row_404(client):
session, _, _ = _login(client)
r = client.get("/db/pages/999999/open/api", cookies={"flowdeck_session": session})
assert r.status_code == 404
def test_row_title_sync_to_content_page(client):
session, csrf, _ = _login(client)
cid = _add_collection(client)
rid = _add_row(client, cid, "Original")
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
r = client.put(
f"/db/pages/{rid}/api",
json={"title": "Renamed row"},
headers=_hdr(csrf),
cookies={"flowdeck_session": session},
)
assert r.status_code == 200, r.text
assert _raw_page_content(pid)["title"] == "Renamed row"
def test_content_page_title_sync_to_row(client):
session, csrf, _ = _login(client)
cid = _add_collection(client)
rid = _add_row(client, cid, "Before")
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
r = client.put(
f"/api/pages/{pid}/rename",
json={"title": "Renamed in editor"},
headers=_hdr(csrf),
cookies={"flowdeck_session": session},
)
assert r.status_code == 200, r.text
with get_conn() as conn:
row = conn.execute("SELECT title FROM collection_pages WHERE id=?", (rid,)).fetchone()
assert row["title"] == "Renamed in editor"
def test_row_delete_cascades_content_page(client):
session, csrf, _ = _login(client)
cid = _add_collection(client)
rid = _add_row(client, cid, "Doomed")
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
r = client.delete(
f"/db/pages/{rid}/api",
headers=_hdr(csrf),
cookies={"flowdeck_session": session},
)
assert r.status_code == 200, r.text
assert _raw_page_content(pid) is None
def test_content_page_restored_from_trash(client):
session, csrf, _ = _login(client)
cid = _add_collection(client)
rid = _add_row(client, cid, "Trashed")
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
# Simulate the user trashing the content page from the editor menu.
client.post(f"/api/pages/{pid}/trash", headers=_hdr(csrf),
cookies={"flowdeck_session": session})
assert _raw_page_content(pid)["deleted_at"]
r = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session})
assert r.json()["page_id"] == pid
page = _raw_page_content(pid)
assert page["deleted_at"] is None
assert page["parent_section"] == "DbRow"
# ── Listings exclusion ───────────────────────────────────────────
def test_sidebar_tree_excludes_content_pages(client):
session, _, uid = _login(client)
cid = _add_collection(client)
rid = _add_row(client, cid, "Shadow row")
pid = client.get(f"/db/pages/{rid}/open/api").json()["page_id"]
with get_conn() as conn:
conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, "
"content_format, parent_section) VALUES ('ws/tree', NULL, "
"'Visible page', '', 'blocks', 'Private')"
)
conn.execute(
"UPDATE pages SET workspace='ws/tree' WHERE id=? OR id=?",
(pid, pid),
)
conn.commit()
from app.routers.board import _build_page_tree
items = _build_page_tree(conn, None, "ws/tree")
titles = {i["name"] for i in items}
assert "Visible page" in titles
assert "Shadow row" not in titles
def test_workspace_tree_excludes_content_pages(client):
from app.routers.dashboard import _build_tree_children
session, csrf, uid = _login(client)
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('WS65', ?)", (uid,)
)
ws_id = cur.lastrowid
conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, "
"content_format, parent_section) VALUES ('', ?, 'Visible', '', "
"'blocks', 'Workspace')",
(ws_id,),
)
conn.commit()
# a content page living in the same workspace (link preserved →
# the IS NULL filter is what must hide it)
cid = _add_collection(client)
rid = _add_row(client, cid, "Hidden row")
with get_conn() as conn:
conn.execute(
"UPDATE pages SET workspace_id=? WHERE collection_row_id=?",
(ws_id, rid),
)
conn.commit()
rows = conn.execute(
"SELECT title FROM pages WHERE parent_id IS NULL AND workspace_id=? "
"AND deleted_at IS NULL AND collection_row_id IS NULL",
(ws_id,),
).fetchall()
items = _build_tree_children(conn, None, ws_id, uid)
titles = {r["title"] for r in rows}
item_titles = {i.get("title") or i.get("name") for i in items}
assert "Visible" in titles
assert "Hidden row" not in titles
assert "Hidden row" not in item_titles
# ── Server-side resolution on read ───────────────────────────────
def _page_with_stale_synced(client, session, csrf, uid):
"""Page whose stored synced cache is stale (source updated offline)."""
sid = create_synced_block(
workspace="v65", title="Src",
content=[{"type": "paragraph", "content": "VERSION_ONE"}],
created_by=uid,
)
page_id = _create_page(client, session, csrf, "Reader")
r = client.post(
f"/board/api/pages/{page_id}/blocks",
json={
"blocks": [
{"type": "paragraph", "content": "intro", "id": "b1"},
{"type": "synced", "synced_id": sid, "id": "b2",
"_synced_content": [{"type": "paragraph", "content": "VERSION_ONE"}],
"_synced_title": "Src"},
],
"title": "Reader",
},
headers=_hdr(csrf),
cookies={"flowdeck_session": session},
)
assert r.status_code == 200, r.text
# Source changes WITHOUT going through the API (worst case: stale cache).
update_synced_block(sid, "Src", [{"type": "paragraph", "content": "VERSION_TWO"}])
return sid, page_id
def test_content_api_resolves_stale_synced_cache(client):
session, csrf, uid = _login(client)
sid, page_id = _page_with_stale_synced(client, session, csrf, uid)
# stored content is still stale…
assert "VERSION_ONE" in _raw_page_content(page_id)["content"]
r = client.get(f"/api/pages/{page_id}/content")
assert r.status_code == 200
blocks = json.loads(r.json()["content"])
synced = [b for b in blocks if b.get("type") == "synced"][0]
assert synced["_synced_content"] == [{"type": "paragraph", "content": "VERSION_TWO"}]
def test_editor_render_resolves_stale_synced_cache(client):
session, csrf, uid = _login(client)
sid, page_id = _page_with_stale_synced(client, session, csrf, uid)
r = client.get(f"/pages/{page_id}", cookies={"flowdeck_session": session})
assert r.status_code == 200
# page_data JSON embedded in the editor contains the fresh content
assert "VERSION_TWO" in r.text
def test_public_page_resolves_and_renders_synced(client):
session, csrf, uid = _login(client)
sid, page_id = _page_with_stale_synced(client, session, csrf, uid)
with get_conn() as conn:
conn.execute(
"UPDATE pages SET is_published=1, published=1, publish_slug='v65pub' "
"WHERE id=?",
(page_id,),
)
conn.commit()
r = client.get("/p/v65pub")
assert r.status_code == 200
assert "VERSION_TWO" in r.text
# no raw block JSON leaked into the public HTML
assert '{"type"' not in r.text
# ── Propagation rewrites stored content ──────────────────────────
def test_put_synced_rewrites_referencing_pages(client):
session, csrf, uid = _login(client)
sid = create_synced_block(
workspace="v65", title="Src",
content=[{"type": "paragraph", "content": "V1"}], created_by=uid,
)
page_id = _create_page(client, session, csrf, "Ref page")
r = client.post(
f"/board/api/pages/{page_id}/blocks",
json={"blocks": [{"type": "synced", "synced_id": sid, "id": "b1",
"_synced_content": [{"type": "paragraph", "content": "V1"}]}],
"title": "Ref page"},
headers=_hdr(csrf),
cookies={"flowdeck_session": session},
)
assert r.status_code == 200
r = client.put(
f"/board/api/synced-blocks/{sid}",
json={"title": "Src", "content": [{"type": "paragraph", "content": "V3"}]},
headers=_hdr(csrf),
cookies={"flowdeck_session": session},
)
assert r.status_code == 200, r.text
# The page's STORED content was rewritten (not just resolved at read).
raw = _raw_page_content(page_id)["content"]
assert "V3" in raw
assert "V1" not in raw
def test_propagation_recurses_into_children(client):
session, csrf, uid = _login(client)
sid = create_synced_block(
workspace="v65", title="Nested",
content=[{"type": "paragraph", "content": "OLD"}], created_by=uid,
)
page_id = _create_page(client, session, csrf, "Columns page")
r = client.post(
f"/board/api/pages/{page_id}/blocks",
json={"blocks": [
{"type": "columns", "id": "cols", "children": [
{"type": "column", "id": "c1", "children": [
{"type": "synced", "synced_id": sid, "id": "n1",
"_synced_content": [{"type": "paragraph", "content": "OLD"}]},
]},
]},
], "title": "Columns page"},
headers=_hdr(csrf),
cookies={"flowdeck_session": session},
)
assert r.status_code == 200
assert get_page_synced(page_id), "nested synced block must be tracked"
r = client.put(
f"/board/api/synced-blocks/{sid}",
json={"title": "Nested", "content": [{"type": "paragraph", "content": "NEW"}]},
headers=_hdr(csrf),
cookies={"flowdeck_session": session},
)
assert r.status_code == 200
raw = _raw_page_content(page_id)["content"]
assert "NEW" in raw
assert "OLD" not in raw
def test_delete_source_marks_instances_deleted(client):
session, csrf, uid = _login(client)
sid = create_synced_block(
workspace="v65", title="Doomed",
content=[{"type": "paragraph", "content": "X"}], created_by=uid,
)
page_id = _create_page(client, session, csrf, "Victim")
r = client.post(
f"/board/api/pages/{page_id}/blocks",
json={"blocks": [{"type": "synced", "synced_id": sid, "id": "b1",
"_synced_content": [{"type": "paragraph", "content": "X"}]}],
"title": "Victim"},
headers=_hdr(csrf),
cookies={"flowdeck_session": session},
)
assert r.status_code == 200
r = client.delete(
f"/board/api/synced-blocks/{sid}",
headers=_hdr(csrf),
cookies={"flowdeck_session": session},
)
assert r.status_code == 200, r.text
assert get_synced_block(sid) is None
assert get_page_synced(page_id) == [] # refs cascaded away
raw = json.loads(_raw_page_content(page_id)["content"])
synced = [b for b in raw if b.get("type") == "synced"][0]
assert synced.get("_synced_deleted") is True
assert "_synced_content" not in synced
# read path agrees
r = client.get(f"/api/pages/{page_id}/content")
blocks = json.loads(r.json()["content"])
assert [b for b in blocks if b.get("type") == "synced"][0].get("_synced_deleted") is True
# ── Synced blocks inside database rows (the v6 point) ────────────
def test_synced_block_lives_and_syncs_inside_a_db_row(client):
session, csrf, uid = _login(client)
cid = _add_collection(client)
rid = _add_row(client, cid, "Row with content")
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
sid = create_synced_block(
workspace="v65", title="Row synced",
content=[{"type": "paragraph", "content": "ROW_V1"}], created_by=uid,
)
r = client.post(
f"/board/api/pages/{pid}/blocks",
json={"blocks": [{"type": "synced", "synced_id": sid, "id": "b1",
"_synced_content": [{"type": "paragraph", "content": "ROW_V1"}]}],
"title": "Row with content"},
headers=_hdr(csrf),
cookies={"flowdeck_session": session},
)
assert r.status_code == 200, r.text
assert any(x["synced_block_id"] == sid for x in get_page_synced(pid))
# saving blocks with a title synced the row title too
with get_conn() as conn:
assert conn.execute("SELECT title FROM collection_pages WHERE id=?", (rid,)).fetchone()["title"] == "Row with content"
# update the source → the ROW's content page is rewritten
r = client.put(
f"/board/api/synced-blocks/{sid}",
json={"title": "Row synced", "content": [{"type": "paragraph", "content": "ROW_V2"}]},
headers=_hdr(csrf),
cookies={"flowdeck_session": session},
)
assert r.status_code == 200
assert "ROW_V2" in _raw_page_content(pid)["content"]
def test_api_v2_row_exposes_content_page_id(client):
r = client.post("/auth/register", json={
"email": "[email protected]", "password": "secret123", "name": "v65api",
})
assert r.status_code == 200, r.text
tok = client.post("/api/v1/token").json()["token"]
headers = {"Authorization": f"Bearer {tok}"}
cid = _add_collection(client)
rid = _add_row(client, cid, "Api row")
pid = client.get(f"/db/pages/{rid}/open/api").json()["page_id"]
r = client.get(f"/api/v2/pages/{rid}", headers=headers)
assert r.status_code == 200, r.text
assert r.json().get("content_page_id") == pid
# row without content page → null (no lazy creation on read)
rid2 = _add_row(client, cid, "No content yet")
r = client.get(f"/api/v2/pages/{rid2}", headers=headers)
assert r.json().get("content_page_id") is None