"""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": "v65api@test.dev", "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