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:
@@ -1024,6 +1024,25 @@ def test_pwa_manifest(client):
|
||||
data = resp.json()
|
||||
assert data["name"] == "FlowDeck"
|
||||
assert data["display"] == "standalone"
|
||||
assert any(i.get("sizes") == "192x192" for i in data["icons"])
|
||||
|
||||
|
||||
def test_pwa_manifest_static_assets():
|
||||
"""v6.0.0: the static manifest and every referenced icon must exist on disk."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
manifest_path = root / "static" / "manifest.json"
|
||||
assert manifest_path.is_file(), "static/manifest.json missing"
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
assert manifest["name"] == "FlowDeck"
|
||||
assert "icons" in manifest and len(manifest["icons"]) >= 8
|
||||
|
||||
for icon in manifest["icons"]:
|
||||
src = icon["src"].lstrip("/")
|
||||
assert (root / src).is_file(), f"icon missing: {src}"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""FlowDeck — PWA offline client module tests (v6.0.0).
|
||||
|
||||
Covers static/js/offline.js (IndexedDB + sync queue), its inclusion in the
|
||||
templates and the editor's offline save hook. The server-side endpoints are
|
||||
exercised in tests/test_sync.py.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OFFLINE_JS = ROOT / "static" / "js" / "offline.js"
|
||||
|
||||
|
||||
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": 1})
|
||||
|
||||
|
||||
def _create_user_ws(client, login="offlinetest", ws_name="Offline WS"):
|
||||
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.com"),
|
||||
)
|
||||
user_id = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
|
||||
(ws_name, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return _token(user_id, login)
|
||||
|
||||
|
||||
# ── static asset ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_offline_js_served(client):
|
||||
resp = client.get("/static/js/offline.js")
|
||||
assert resp.status_code == 200
|
||||
assert "javascript" in resp.headers.get("content-type", "")
|
||||
|
||||
|
||||
def test_offline_js_exposes_public_api():
|
||||
src = OFFLINE_JS.read_text(encoding="utf-8")
|
||||
assert "window.FlowOffline" in src
|
||||
for api in ("enqueue", "flush", "delta", "status", "savePageOffline",
|
||||
"getQueue", "pendingCount", "onChange"):
|
||||
assert api in src, f"missing FlowOffline API: {api}"
|
||||
|
||||
|
||||
def test_offline_js_indexeddb_schema():
|
||||
src = OFFLINE_JS.read_text(encoding="utf-8")
|
||||
assert "flowdeck-offline" in src
|
||||
for store in ("pages_offline", "collections_offline", "sync_queue", "sync_meta"):
|
||||
assert store in src, f"missing IndexedDB store: {store}"
|
||||
|
||||
|
||||
def test_offline_js_targets_sync_endpoints():
|
||||
src = OFFLINE_JS.read_text(encoding="utf-8")
|
||||
assert "/api/v2/sync/batch" in src
|
||||
assert "/api/v2/sync/delta" in src
|
||||
assert "/api/v2/sync/status" in src
|
||||
# mutation shape expected by the server engine
|
||||
for field in ("mutation_id", "client_timestamp", "device_id"):
|
||||
assert field in src, f"missing mutation field: {field}"
|
||||
|
||||
|
||||
def test_offline_js_background_sync():
|
||||
src = OFFLINE_JS.read_text(encoding="utf-8")
|
||||
assert "sync-flowdeck" in src
|
||||
assert "registerBackgroundSync" in src
|
||||
|
||||
|
||||
# ── template wiring ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_base_includes_offline_module(client):
|
||||
base = (ROOT / "app" / "templates" / "base.html").read_text(encoding="utf-8")
|
||||
assert "/static/js/offline.js" in base
|
||||
assert "/sw.js" in base
|
||||
# served on a base.html page as well
|
||||
session = _create_user_ws(client, login="basemod")
|
||||
resp = client.get("/local-workspace", cookies={"flowdeck_session": session})
|
||||
assert resp.status_code == 200
|
||||
assert "/static/js/offline.js" in resp.text
|
||||
|
||||
|
||||
def test_workspace_offline_banner_shows_pending(client):
|
||||
session = _create_user_ws(client)
|
||||
resp = client.get("/local-workspace", cookies={"flowdeck_session": session})
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert "offline-banner" in body
|
||||
assert "pendingSyncCount" in body
|
||||
assert "FlowOffline" in body
|
||||
|
||||
|
||||
def test_editor_save_has_offline_hook():
|
||||
src = (ROOT / "app" / "templates" / "_page_editor_scripts.html").read_text(encoding="utf-8")
|
||||
assert "FlowOffline" in src
|
||||
assert "savePageOffline" in src
|
||||
assert "navigator.onLine" in src
|
||||
|
||||
|
||||
def test_sw_precache_includes_offline_js():
|
||||
sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
|
||||
urls = re.findall(r"'(/static/js/offline\.js[^']*)'", sw)
|
||||
assert urls, "offline.js missing from SW precache"
|
||||
for url in urls:
|
||||
assert (ROOT / url.split("?")[0].lstrip("/")).is_file()
|
||||
|
||||
|
||||
# ── phase 6: hardening + sync UI ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_offline_js_batch_limits_and_timeout():
|
||||
src = OFFLINE_JS.read_text(encoding="utf-8")
|
||||
assert "MAX_BATCH" in src
|
||||
assert "FETCH_TIMEOUT_MS" in src
|
||||
assert "AbortController" in src
|
||||
assert "fetchWithTimeout" in src
|
||||
assert "purgeOld" in src
|
||||
assert "QUEUE_RETENTION_MS" in src
|
||||
|
||||
|
||||
def test_offline_js_dirty_markers_and_sync_event():
|
||||
src = OFFLINE_JS.read_text(encoding="utf-8")
|
||||
assert "getDirtyPageIds" in src
|
||||
assert "markDirtySidebar" in src
|
||||
assert "fd-dirty" in src
|
||||
assert "flowdeck:sync-done" in src
|
||||
|
||||
|
||||
def test_base_has_sync_badge_and_toast_wiring(client):
|
||||
base = (ROOT / "app" / "templates" / "base.html").read_text(encoding="utf-8")
|
||||
assert "fd-sync-badge" in base
|
||||
assert "fd-sync-spinner" in base
|
||||
assert "fd-dirty" in base
|
||||
assert "flowdeck:sync-done" in base
|
||||
# rendered on an authenticated base.html page
|
||||
session = _create_user_ws(client, login="syncbadge")
|
||||
resp = client.get("/local-workspace", cookies={"flowdeck_session": session})
|
||||
assert resp.status_code == 200
|
||||
assert "fd-sync-badge" in resp.text
|
||||
|
||||
|
||||
# ── phase 7: E2E spec (runs against a live server via Playwright) ───────────
|
||||
|
||||
|
||||
def test_e2e_pwa_offline_spec_present():
|
||||
spec = ROOT / "e2e" / "pwa_offline.spec.js"
|
||||
assert spec.is_file()
|
||||
src = spec.read_text(encoding="utf-8")
|
||||
assert "FlowOffline" in src
|
||||
assert "/api/v2/sync/delta" in src
|
||||
assert "setOffline(true)" in src
|
||||
assert "setOffline(false)" in src
|
||||
assert "navigator.serviceWorker" in src
|
||||
assert "manifest" in src
|
||||
# skips gracefully when the server is not running
|
||||
assert "test.skip" in src
|
||||
|
||||
|
||||
# ── phase 8: release docs + version ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_version_bumped_to_6_0_0():
|
||||
assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "6.0.0"
|
||||
assert 'version="6.0.0"' in (ROOT / "app" / "main.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_help_documents_offline_mode(client):
|
||||
session = _create_user_ws(client, login="helpuser")
|
||||
resp = client.get("/help", cookies={"flowdeck_session": session})
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert "Offline mode (PWA)" in body
|
||||
assert "IndexedDB" in body
|
||||
|
||||
|
||||
def test_changelog_and_roadmap_mention_v6(client):
|
||||
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
|
||||
assert "## v6.0.0" in changelog
|
||||
assert "PWA" in changelog
|
||||
assert "v6.0.0 — PWA" in roadmap
|
||||
@@ -0,0 +1,112 @@
|
||||
"""FlowDeck — PWA service worker tests (v6.0.0).
|
||||
|
||||
Validates that the service worker, static manifest and PWA icons are served
|
||||
correctly (status codes, content types) and stay in sync with the manifest.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
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": 1})
|
||||
|
||||
|
||||
def _create_user_ws(client, login="pwatest", ws_name="PWA WS"):
|
||||
"""Insert a user + workspace and return an authenticated session cookie."""
|
||||
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.com"),
|
||||
)
|
||||
user_id = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
|
||||
(ws_name, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
return _token(user_id, login)
|
||||
|
||||
|
||||
def test_sw_served_at_top_level(client):
|
||||
"""GET /sw.js returns the JS source so registration at scope '/' works."""
|
||||
resp = client.get("/sw.js")
|
||||
assert resp.status_code == 200
|
||||
assert "javascript" in resp.headers.get("content-type", "")
|
||||
|
||||
|
||||
def test_sw_served_via_static_mount(client):
|
||||
resp = client.get("/static/sw.js")
|
||||
assert resp.status_code == 200
|
||||
assert "javascript" in resp.headers.get("content-type", "")
|
||||
|
||||
|
||||
def test_sw_registration_present_on_landing(client):
|
||||
"""Anonymous entry point (/) registers the SW (assets are public)."""
|
||||
resp = client.get("/")
|
||||
assert resp.status_code in (200, 302)
|
||||
body = resp.text
|
||||
assert "serviceWorker" in body
|
||||
assert "/sw.js" in body
|
||||
|
||||
|
||||
def test_sw_registration_with_background_sync_in_base(client):
|
||||
"""base.html pages register /sw.js and the background-sync tag."""
|
||||
session = _create_user_ws(client)
|
||||
resp = client.get("/local-workspace", cookies={"flowdeck_session": session})
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert "serviceWorker" in body
|
||||
assert "/sw.js" in body
|
||||
assert "sync-flowdeck" in body
|
||||
|
||||
|
||||
def test_base_has_pwa_meta_tags(client):
|
||||
resp = client.get("/")
|
||||
body = resp.text
|
||||
assert 'rel="manifest"' in body
|
||||
assert "theme-color" in body
|
||||
assert "apple-touch-icon" in body
|
||||
|
||||
|
||||
def test_manifest_self_consistent(client):
|
||||
"""Every icon listed in the served manifest exists on disk."""
|
||||
resp = client.get("/manifest.json")
|
||||
assert resp.status_code == 200
|
||||
manifest = resp.json()
|
||||
|
||||
for icon in manifest["icons"]:
|
||||
path = ROOT / icon["src"].lstrip("/")
|
||||
assert path.is_file(), f"manifest references missing icon: {icon['src']}"
|
||||
|
||||
pwa_manifest = ROOT / "static" / "manifest.json"
|
||||
assert pwa_manifest.is_file()
|
||||
assert json.loads(pwa_manifest.read_text(encoding="utf-8")) == manifest
|
||||
|
||||
|
||||
def test_sw_precache_urls_exist():
|
||||
"""Every URL listed in the SW precache maps to a served static asset."""
|
||||
sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
|
||||
urls = re.findall(r"'(/static/[^']+)'", sw)
|
||||
|
||||
assert urls, "no static URLs found in precache list"
|
||||
for url in urls:
|
||||
file_path = ROOT / url.split("?")[0].lstrip("/")
|
||||
assert file_path.is_file(), f"precache URL missing on disk: {url}"
|
||||
|
||||
|
||||
def test_sw_cache_strategies_defined():
|
||||
"""The SW must define both cache-first and network-first helpers."""
|
||||
sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8")
|
||||
assert "cacheFirst" in sw
|
||||
assert "networkFirst" in sw
|
||||
assert "CACHE_NAME" in sw
|
||||
assert "addEventListener('install'" in sw
|
||||
assert "addEventListener('activate'" in sw
|
||||
assert "addEventListener('fetch'" in sw
|
||||
assert "addEventListener('sync'" in sw
|
||||
@@ -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"
|
||||
@@ -0,0 +1,125 @@
|
||||
"""FlowDeck — v6.0.0 PWA: offline-sync schema migrations.
|
||||
|
||||
Validates the ``offline_sync_queue`` table, the ``sync_version`` columns and
|
||||
the BEFORE-UPDATE triggers that bump them on every write.
|
||||
"""
|
||||
from app.db import get_conn
|
||||
|
||||
|
||||
def _insert_page(title="Page"):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
|
||||
"VALUES ('', ?, '', 'blocks', 'Private')",
|
||||
(title,),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _insert_collection(name="Coll"):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collections (name, schema_json) VALUES (?, '[]')", (name,)
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _insert_collection_page(coll_id, title="Row"):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title) VALUES (?, ?)",
|
||||
(coll_id, title),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def test_offline_sync_queue_table_and_indexes(client):
|
||||
with get_conn() as conn:
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(offline_sync_queue)")}
|
||||
for expected in ("id", "user_id", "device_id", "type", "payload",
|
||||
"client_timestamp", "server_version", "status",
|
||||
"retries", "error", "created_at"):
|
||||
assert expected in cols, f"offline_sync_queue missing column {expected}"
|
||||
|
||||
indexes = {r[1] for r in conn.execute("PRAGMA index_list('offline_sync_queue')")}
|
||||
assert "idx_syncqueue_user" in indexes
|
||||
assert "idx_syncqueue_device" in indexes
|
||||
|
||||
|
||||
def test_offline_sync_queue_insert(client):
|
||||
with get_conn() as conn:
|
||||
uid = conn.execute(
|
||||
"INSERT INTO users (login, full_name, email) VALUES ('queuetest', 'Queue', '[email protected]')"
|
||||
).lastrowid
|
||||
cur = conn.execute(
|
||||
"INSERT INTO offline_sync_queue (user_id, device_id, type, payload, client_timestamp) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(uid, "dev-abc", "page_update", '{"page_id": 1}', 1750000000000.0),
|
||||
)
|
||||
conn.commit()
|
||||
qid = cur.lastrowid
|
||||
row = conn.execute(
|
||||
"SELECT status, server_version, retries FROM offline_sync_queue WHERE id=?",
|
||||
(qid,),
|
||||
).fetchone()
|
||||
assert row["status"] == "pending"
|
||||
assert row["server_version"] == 0
|
||||
assert row["retries"] == 0
|
||||
|
||||
|
||||
def test_sync_version_column_and_defaults(client):
|
||||
pid = _insert_page()
|
||||
cid = _insert_collection()
|
||||
cpid = _insert_collection_page(cid)
|
||||
with get_conn() as conn:
|
||||
for table, pk in (("pages", pid), ("collection_pages", cpid), ("collections", cid)):
|
||||
row = conn.execute(f"SELECT sync_version FROM {table} WHERE id=?", (pk,)).fetchone()
|
||||
assert row is not None, f"sync_version missing on {table}"
|
||||
assert row["sync_version"] == 1, f"{table} default sync_version should be 1"
|
||||
|
||||
|
||||
def test_sync_version_increments_on_update(client):
|
||||
pid = _insert_page()
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title='Renamed' WHERE id=?", (pid,))
|
||||
conn.commit()
|
||||
v1 = conn.execute("SELECT sync_version FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
conn.execute("UPDATE pages SET content='[...]' WHERE id=?", (pid,))
|
||||
conn.commit()
|
||||
v2 = conn.execute("SELECT sync_version FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
assert v1["sync_version"] == 2
|
||||
assert v2["sync_version"] == 3
|
||||
|
||||
|
||||
def test_sync_version_increments_on_soft_delete(client):
|
||||
"""deleted_at changes must bump the version too (delta sync sees deletions)."""
|
||||
pid = _insert_page()
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
|
||||
(pid,),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT sync_version FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
assert row["sync_version"] == 2
|
||||
|
||||
|
||||
def test_sync_version_increments_collection_and_rows(client):
|
||||
cid = _insert_collection()
|
||||
cpid = _insert_collection_page(cid)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE collections SET name='Renamed' WHERE id=?", (cid,))
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET title='Row2', property_values_json='{}' WHERE id=?",
|
||||
(cpid,),
|
||||
)
|
||||
conn.commit()
|
||||
col = conn.execute("SELECT sync_version FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
row = conn.execute(
|
||||
"SELECT sync_version FROM collection_pages WHERE id=?", (cpid,)
|
||||
).fetchone()
|
||||
assert col["sync_version"] == 2
|
||||
assert row["sync_version"] == 2
|
||||
Reference in New Issue
Block a user