"""FlowDeck — v6.0.0 PWA offline sync: engine + /api/v2/sync endpoints.""" import time from conftest import anon # ── helpers ──────────────────────────────────────────────────────────────── def _token(user_id, login): from app.auth.session import SessionManager return SessionManager.create_session({"id": user_id, "login": login, "full_name": login.title(), "is_admin": 0}) def _user_ws(client, login="syncer"): """Create user + workspace, return (session_cookie, user_id, workspace_id).""" from app.db import get_conn with get_conn() as conn: cur = conn.execute( "INSERT INTO users (login, full_name, email) VALUES (?, ?, ?)", (login, login.title(), f"{login}@test.co"), ) uid = cur.lastrowid ws = conn.execute( "INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", ("Sync WS", uid) ) conn.commit() return _token(uid, login), uid, ws.lastrowid def _insert_page(ws_id, title="P", content="[]", content_format="blocks"): from app.db import get_conn with get_conn() as conn: cur = conn.execute( "INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section) " "VALUES ('', ?, ?, ?, ?, 'Private')", (ws_id, title, content, content_format), ) conn.commit() return cur.lastrowid def _page_version(pid): from app.db import get_conn with get_conn() as conn: row = conn.execute("SELECT sync_version FROM pages WHERE id=?", (pid,)).fetchone() return row["sync_version"] def _page_updated_epoch(pid) -> float: """Epoch (float) of the page's stored updated_at (second granularity).""" from app.db import get_conn with get_conn() as conn: row = conn.execute("SELECT strftime('%s', updated_at) AS s FROM pages WHERE id=?", (pid,)).fetchone() return float(row["s"]) # ── authentication & routing ─────────────────────────────────────────────── def test_sync_requires_auth(client): anon(client) assert client.get("/api/v2/sync/status").status_code == 401 assert client.post("/api/v2/sync/batch", json={"mutations": []}).status_code == 401 assert client.get("/api/v2/sync/delta").status_code == 401 def test_delta_requires_workspace(client): sess, _, _ = _user_ws(client) resp = client.get("/api/v2/sync/delta", cookies={"flowdeck_session": sess}) assert resp.status_code == 400 def test_delta_forbidden_other_user_workspace(client): session, _, ws_id = _user_ws(client, "owner_a") sess2, uid2, _ = _user_ws(client, "owner_b") # owner_b has no access to owner_a's workspace resp = client.get( f"/api/v2/sync/delta?since=0&workspace_id={ws_id}", cookies={"flowdeck_session": sess2}, ) assert resp.status_code == 403 # ── delta ────────────────────────────────────────────────────────────────── def test_delta_returns_changes_since(client): session, _, ws_id = _user_ws(client) pid = _insert_page(ws_id, "D1", "[]") since = _page_updated_epoch(pid) time.sleep(1.1) # CURRENT_TIMESTAMP has 1s granularity → ensure next second _insert_page(ws_id, "D2", "[]") resp = client.get( f"/api/v2/sync/delta?since={since}&workspace_id={ws_id}", cookies={"flowdeck_session": session}, ) assert resp.status_code == 200 changes = resp.json()["changes"] assert len(changes) == 1 assert changes[0]["change_type"] == "page_created" assert changes[0]["data"]["title"] == "D2" # full history from since=0 resp2 = client.get( f"/api/v2/sync/delta?since=0&workspace_id={ws_id}", cookies={"flowdeck_session": session}, ) titles = {c["data"]["title"] for c in resp2.json()["changes"] if c["change_type"].startswith("page_")} assert {"D1", "D2"} <= titles def test_delta_includes_soft_deleted(client): from app.db import get_conn session, _, ws_id = _user_ws(client) pid = _insert_page(ws_id, "ToDelete") time.sleep(1.1) # 1s granularity: ensure the delete lands in a later second since = _page_updated_epoch(pid) with get_conn() as conn: conn.execute("UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?", (pid,)) conn.commit() resp = client.get( f"/api/v2/sync/delta?since={since}&workspace_id={ws_id}", cookies={"flowdeck_session": session}, ) changes = resp.json()["changes"] assert any(c["change_type"] == "page_deleted" for c in changes) # ── batch: page mutations ────────────────────────────────────────────────── def test_batch_page_create(client): session, _, ws_id = _user_ws(client) resp = client.post( "/api/v2/sync/batch", json={ "device_id": "dev-1", "mutations": [{ "id": "m1", "type": "page_create", "client_timestamp": time.time(), "payload": {"title": "Offline Page", "content": "[]", "content_format": "blocks", "workspace_id": ws_id}, }], }, cookies={"flowdeck_session": session}, ) assert resp.status_code == 200 body = resp.json() assert body["results"][0]["status"] == "synced" pid = body["results"][0]["page_id"] assert body["results"][0]["server_version"] == 1 from app.db import get_conn with get_conn() as conn: row = conn.execute("SELECT title, workspace_id FROM pages WHERE id=?", (pid,)).fetchone() assert row["title"] == "Offline Page" assert row["workspace_id"] == ws_id def test_batch_page_update_synced(client): session, _, ws_id = _user_ws(client) pid = _insert_page(ws_id, "Versioned", "[]") resp = client.post( "/api/v2/sync/batch", json={"device_id": "dev-1", "mutations": [{ "id": "m1", "type": "page_update", "client_timestamp": time.time(), "payload": {"page_id": pid, "base_version": 1, "title": "Versioned v2", "content": "[1,2,3]"}, }]}, cookies={"flowdeck_session": session}, ) r = resp.json()["results"][0] assert r["status"] == "synced" assert r["server_version"] == 2 # trigger bumped 1 → 2 def test_batch_page_update_edit_edit_conflict_lww(client): """Server page already moved to v2 → client base v1 → LWW applied + conflict.""" session, _, ws_id = _user_ws(client) pid = _insert_page(ws_id, "Conflict", "[]") from app.db import get_conn with get_conn() as conn: conn.execute("UPDATE pages SET content='[server edit]' WHERE id=?", (pid,)) conn.commit() assert _page_version(pid) == 2 resp = client.post( "/api/v2/sync/batch", json={"device_id": "dev-1", "mutations": [{ "id": "m2", "type": "page_update", "client_timestamp": time.time(), "payload": {"page_id": pid, "base_version": 1, "content": "[client edit]"}, }]}, cookies={"flowdeck_session": session}, ) r = resp.json()["results"][0] assert r["status"] == "conflict" assert r["conflict"]["type"] == "edit_edit" assert r["conflict"]["server_version"] == 2 assert r["server_version"] == 3 # LWW applied # content overwritten by client (last write wins) from app.db import get_conn with get_conn() as conn: content = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()["content"] assert content == "[client edit]" def test_batch_page_update_edit_delete_orphan(client): session, _, ws_id = _user_ws(client) pid = _insert_page(ws_id, "Deleted", "[old]") from app.db import get_conn with get_conn() as conn: conn.execute("UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?", (pid,)) conn.commit() resp = client.post( "/api/v2/sync/batch", json={"device_id": "dev-1", "mutations": [{ "id": "m3", "type": "page_update", "client_timestamp": time.time(), "payload": {"page_id": pid, "base_version": 1, "content": "[client]"}, }]}, cookies={"flowdeck_session": session}, ) r = resp.json()["results"][0] assert r["status"] == "conflict" assert r["conflict"]["type"] == "edit_delete" orphan_id = r["conflict"]["new_page_id"] with get_conn() as conn: orphan = conn.execute("SELECT content, deleted_at FROM pages WHERE id=?", (orphan_id,)).fetchone() original = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (pid,)).fetchone() assert orphan["content"] == "[client]" assert not orphan["deleted_at"] # orphan is live assert original["deleted_at"] # original stays deleted def test_batch_page_create_create_create_conflict(client): session, _, ws_id = _user_ws(client) _insert_page(ws_id, "Same Title", "[]") resp = client.post( "/api/v2/sync/batch", json={"device_id": "dev-1", "mutations": [{ "id": "m4", "type": "page_create", "client_timestamp": time.time(), "payload": {"title": "Same Title", "content": "[]", "workspace_id": ws_id}, }]}, cookies={"flowdeck_session": session}, ) r = resp.json()["results"][0] assert r["status"] == "conflict" assert r["conflict"]["type"] == "create_create" assert r["conflict"]["renamed"] is True from app.db import get_conn with get_conn() as conn: title = conn.execute("SELECT title FROM pages WHERE id=?", (r["page_id"],)).fetchone()["title"] assert title == "Same Title (copie offline)" def test_batch_page_delete_idempotent(client): session, _, ws_id = _user_ws(client) pid = _insert_page(ws_id, "D") resp = client.post( "/api/v2/sync/batch", json={"device_id": "dev-1", "mutations": [{ "id": "m5", "type": "page_delete", "client_timestamp": time.time(), "payload": {"page_id": pid}, }]}, cookies={"flowdeck_session": session}, ) assert resp.json()["results"][0]["status"] == "synced" # second delete is still ok (idempotent) resp2 = client.post( "/api/v2/sync/batch", json={"device_id": "dev-1", "mutations": [{ "id": "m6", "type": "page_delete", "client_timestamp": time.time(), "payload": {"page_id": pid}, }]}, cookies={"flowdeck_session": session}, ) assert resp2.json()["results"][0]["status"] == "synced" def test_batch_unknown_type_failed(client): session, _, ws_id = _user_ws(client) resp = client.post( "/api/v2/sync/batch", json={"device_id": "dev-1", "mutations": [{ "id": "m7", "type": "nope", "client_timestamp": time.time(), "payload": {}, }]}, cookies={"flowdeck_session": session}, ) r = resp.json()["results"][0] assert r["status"] == "failed" # ── batch: collections ───────────────────────────────────────────────────── def test_batch_collection_crud(client): from app.db import get_conn session, _, _ = _user_ws(client) resp = client.post( "/api/v2/sync/batch", json={"device_id": "dev-1", "mutations": [{ "id": "c1", "type": "collection_create", "client_timestamp": time.time(), "payload": {"name": "DB Off", "schema": [{"name": "Type", "prop_type": "text"}]}, }]}, cookies={"flowdeck_session": session}, ) r = resp.json()["results"][0] assert r["status"] == "synced" cid = r["collection_id"] resp = client.post( "/api/v2/sync/batch", json={"device_id": "dev-1", "mutations": [{ "id": "c2", "type": "collection_update", "client_timestamp": time.time(), "payload": {"collection_id": cid, "name": "DB Off v2"}, }]}, cookies={"flowdeck_session": session}, ) assert resp.json()["results"][0]["server_version"] == 2 resp = client.post( "/api/v2/sync/batch", json={"device_id": "dev-1", "mutations": [{ "id": "c3", "type": "collection_delete", "client_timestamp": time.time(), "payload": {"collection_id": cid}, }]}, cookies={"flowdeck_session": session}, ) assert resp.json()["results"][0]["status"] == "synced" with get_conn() as conn: assert conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone() is None # ── status & audit ───────────────────────────────────────────────────────── def test_sync_status_zero(client): session, _, ws_id = _user_ws(client) resp = client.get( f"/api/v2/sync/status?workspace_id={ws_id}", cookies={"flowdeck_session": session}, ) body = resp.json() assert body["pending_count"] == 0 assert body["is_syncing"] is False def test_batch_records_audit_rows(client): session, uid, ws_id = _user_ws(client) client.post( "/api/v2/sync/batch", json={"device_id": "audit-dev", "mutations": [{ "id": "a1", "type": "page_create", "client_timestamp": 123.0, "payload": {"title": "Audited", "content": "[]", "workspace_id": ws_id}, }]}, cookies={"flowdeck_session": session}, ) from app.db import get_conn with get_conn() as conn: row = conn.execute( "SELECT user_id, device_id, type, status FROM offline_sync_queue ORDER BY id" ).fetchone() assert row["user_id"] == uid assert row["device_id"] == "audit-dev" assert row["type"] == "page_create" assert row["status"] == "synced"