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
+19
View File
@@ -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}"
# ══════════════════════════════════════════════════════
+188
View File
@@ -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
+112
View File
@@ -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
+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"
+125
View File
@@ -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