feat: v6.5.0 Synced blocks production — pages contenu par lignes de DB, résolution serveur à chaque lecture, propagation écrite réelle
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user