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:
2026-09-18 13:05:40 -04:00
parent 62620ef884
commit b5207216f1
35 changed files with 3115 additions and 23 deletions
+372
View File
@@ -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"