feat: v6.0.0 PWA offline support
- manifest + icones, service worker (precache, network-first, Background Sync)
- module client FlowOffline (IndexedDB, queue, delta, flush) + hook editeur
- endpoints /api/v2/sync/{delta,batch,status} + moteur de sync (conflits LWW/orpheline/copie offline)
- migrations offline_sync_queue + sync_version (triggers)
- UI offline (banner, badge sync, toasts, icone dirty) + doc /help
- tests pytest (sync, migrations, SW, offline) + E2E Playwright; bump 6.0.0
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
"""FlowDeck — v6.0.0 PWA offline sync: engine + /api/v2/sync endpoints."""
|
||||
import time
|
||||
|
||||
# ── 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):
|
||||
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"
|
||||
Reference in New Issue
Block a user