v5.13.0 Realtime : édition collaborative en direct
- Passerelle WebSocket WS /ws/pages/{page_id} (auth cookie, close 4401/4404), rooms par page en mémoire
- Protocole hello/sync/op/ack/title/sel/ping/peer_join/peer_leave ; version de page + stale => resync
- Merge des ops de blocs insert/update/delete/move, last-write-wins par bloc, ordre d'arrivée
- Client éditeur : diff local -> ops (debounce), application distante discret (LWW sur bloc focalisé), re-focus du bloc actif
- Présence (avatars topbar) + curseurs live (calque dédié, positions à l'édition/au scroll)
- Titre synchronisé (debounce) sans écraser le titre en cours d'édition
- Fallback polling 10 s si WS indisponible (adopté seulement sans brouillon local) + reconnexion auto
- Persistance debounce ~1 s (content/title) + flush à la déconnexion du dernier client
- CSP connect-src étendu à ws: ; peers sans données sensibles (id/login/full_name/couleur)
- 12 tests tests/test_realtime.py (auth, page absente, hello->sync, LWW 2 clients + persistance, présence, curseurs, titre, stale resync, apply_op/merge_ops) ; suite 298 verte (3 PDF pré-existants)
- Bump v5.3.0 (VERSION, main.py, CHANGELOG) ; ROADMAP v5.13.0 livré
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
"""FlowDeck — v5.13.0 Realtime: WebSocket gateway, présence, curseurs live,
|
||||
merge LWW des opérations de blocs + version de page + persistance debounce.
|
||||
|
||||
Covers: la route WS /ws/pages/{page_id} (auth via cookie, page introuvable),
|
||||
le protocole hello/sync/op/ack/sel/title/peer_*, le merge last-write-wins et
|
||||
la resynchronisation des clients périmés.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
db_path = db_file.name
|
||||
db_file.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||||
os.environ["APP_SECRET_KEY"] = "test-secret-for-realtime"
|
||||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||||
from app.config import settings
|
||||
settings.database_url = f"sqlite:///{db_path}"
|
||||
|
||||
from app.main import app
|
||||
from app.db import init_db, get_conn
|
||||
init_db()
|
||||
with get_conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (1, 'tester', 'Tester', 1)")
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (2, 'other', 'Other', 0)")
|
||||
conn.commit()
|
||||
|
||||
from app.services.realtime_server import manager
|
||||
manager._rooms = {}
|
||||
|
||||
tc = TestClient(app, raise_server_exceptions=False)
|
||||
yield tc
|
||||
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
def _make_page(title="Realtime Page", blocks=None):
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
|
||||
"VALUES ('Private', ?, ?, 'blocks', 'Private')",
|
||||
(title, json.dumps(blocks or [], ensure_ascii=False)),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
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 _auth_client(client, user_id=1, login="tester"):
|
||||
client.cookies.set("flowdeck_session", _token(user_id, login))
|
||||
|
||||
|
||||
# ── apply_op (service pur) ──
|
||||
|
||||
def test_apply_op_insert():
|
||||
from app.services.realtime_server import apply_op
|
||||
blocks = [{"id": "a", "content": "A"}]
|
||||
out = apply_op(blocks, {"type": "insert", "index": 0,
|
||||
"block": {"id": "b", "content": "B"}})
|
||||
assert [b["id"] for b in out] == ["b", "a"]
|
||||
assert apply_op(blocks, {"type": "insert", "index": None,
|
||||
"block": {"content": "C"}})[-1]["content"] == "C"
|
||||
# index clampé
|
||||
assert apply_op(blocks, {"type": "insert", "index": 99,
|
||||
"block": {"id": "z"}})[-1]["id"] == "z"
|
||||
|
||||
|
||||
def test_apply_op_update_delete_move():
|
||||
from app.services.realtime_server import apply_op
|
||||
blocks = [{"id": "a", "content": "A"}, {"id": "b", "content": "B"},
|
||||
{"id": "c", "content": "C"}]
|
||||
out = apply_op(blocks, {"type": "update", "block": {"id": "b", "content": "B2"}})
|
||||
assert next(x for x in out if x["id"] == "b")["content"] == "B2"
|
||||
out = apply_op(blocks, {"type": "update", "block": {"id": "nope", "content": "X"}})
|
||||
assert out == blocks
|
||||
out = apply_op(blocks, {"type": "delete", "id": "a"})
|
||||
assert [b["id"] for b in out] == ["b", "c"]
|
||||
out = apply_op(blocks, {"type": "move", "id": "c", "index": 0})
|
||||
assert [b["id"] for b in out] == ["c", "a", "b"]
|
||||
out = apply_op(blocks, {"type": "move", "id": "nope", "index": 0})
|
||||
assert out == blocks
|
||||
assert apply_op(blocks, {"type": "unknown"}) == blocks
|
||||
|
||||
|
||||
def test_merge_ops_sequential():
|
||||
from app.services.realtime_server import merge_ops
|
||||
blocks = []
|
||||
ops = [
|
||||
{"type": "insert", "index": 0, "block": {"id": "a", "content": "A"}},
|
||||
{"type": "insert", "index": 1, "block": {"id": "b", "content": "B"}},
|
||||
{"type": "update", "block": {"id": "a", "content": "A2"}},
|
||||
{"type": "move", "id": "b", "index": 0},
|
||||
]
|
||||
assert [x["id"] for x in merge_ops(blocks, ops)] == ["b", "a"]
|
||||
assert next(x for x in merge_ops(blocks, ops) if x["id"] == "a")["content"] == "A2"
|
||||
|
||||
|
||||
# ── Auth & présence de page ──
|
||||
|
||||
def test_ws_requires_auth(client):
|
||||
_make_page()
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/pages/1") as ws:
|
||||
ws.receive_json()
|
||||
assert exc.value.code == 4401
|
||||
|
||||
|
||||
def test_ws_auth_rejected(client):
|
||||
_make_page()
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/pages/1") as ws:
|
||||
ws.receive_json()
|
||||
assert exc.value.code == 4401
|
||||
|
||||
|
||||
def test_ws_missing_page_4404(client):
|
||||
_auth_client(client)
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/pages/9999") as ws:
|
||||
ws.receive_json()
|
||||
assert exc.value.code == 4404
|
||||
|
||||
|
||||
def test_ws_hello_gets_sync(client):
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "Hi"}])
|
||||
_auth_client(client)
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as ws:
|
||||
w = ws.receive_json()
|
||||
assert w["t"] == "welcome"
|
||||
assert w["self"]["id"] == 1
|
||||
s = ws.receive_json()
|
||||
assert s["t"] == "sync"
|
||||
assert s["version"] == 0
|
||||
assert s["blocks"][0]["id"] == "a"
|
||||
assert s["title"] == "Realtime Page"
|
||||
ws.send_json({"t": "hello"})
|
||||
s2 = ws.receive_json()
|
||||
assert s2["t"] == "sync" and s2["blocks"][0]["content"] == "Hi"
|
||||
|
||||
|
||||
# ── merge LWW + broadcast ──
|
||||
|
||||
def test_ws_lww_merge(client):
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "x"},
|
||||
{"id": "b", "type": "paragraph", "content": "y"}])
|
||||
_auth_client(client, 1, "tester")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
|
||||
wa.receive_json() # welcome
|
||||
wa.receive_json() # sync
|
||||
_auth_client(client, 2, "other")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
|
||||
wb.receive_json() # welcome (peer tester)
|
||||
wb.receive_json() # sync
|
||||
wa.receive_json() # peer_join other
|
||||
wa.send_json({"t": "op", "v": 0, "op": {"type": "update",
|
||||
"block": {"id": "a", "type": "paragraph", "content": "first"}}})
|
||||
ack = wa.receive_json() # ack de son propre op
|
||||
assert ack["t"] == "ack" and ack["v"] == 1
|
||||
b_op = wb.receive_json()
|
||||
assert b_op["t"] == "op" and b_op["from"] == 1
|
||||
assert b_op["op"]["block"]["content"] == "first"
|
||||
wb.send_json({"t": "op", "v": 1, "op": {"type": "update",
|
||||
"block": {"id": "a", "type": "paragraph", "content": "second"}}})
|
||||
wb.receive_json() # ack de son propre op
|
||||
a_op = wa.receive_json()
|
||||
assert a_op["t"] == "op" and a_op["from"] == 2
|
||||
assert a_op["op"]["block"]["content"] == "second"
|
||||
# dernier arrivé gagne (LWW) et persistance au disconnect
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()
|
||||
d = json.loads(row["content"])
|
||||
assert d[0]["content"] == "second"
|
||||
|
||||
|
||||
# ── présence ──
|
||||
|
||||
def test_ws_presence_join_leave(client):
|
||||
pid = _make_page()
|
||||
_auth_client(client, 1, "tester")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
|
||||
wa.receive_json(); wa.receive_json()
|
||||
_auth_client(client, 2, "other")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
|
||||
w_f = wb.receive_json() # welcome
|
||||
assert w_f["t"] == "welcome"
|
||||
assert any(p["id"] == 1 for p in w_f["peers"])
|
||||
wb.receive_json() # sync
|
||||
pj = wa.receive_json() # peer_join other
|
||||
assert pj["t"] == "peer_join" and pj["peer"]["id"] == 2
|
||||
pl = wa.receive_json() # peer_leave other
|
||||
assert pl["t"] == "peer_leave" and pl["id"] == 2
|
||||
|
||||
|
||||
def test_ws_cursor_broadcast(client):
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "l"}])
|
||||
_auth_client(client, 1, "tester")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
|
||||
wa.receive_json(); wa.receive_json()
|
||||
_auth_client(client, 2, "other")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
|
||||
wb.receive_json(); wb.receive_json()
|
||||
wa.receive_json() # peer_join
|
||||
wa.send_json({"t": "sel", "block": "a", "offset": 1})
|
||||
m = wb.receive_json()
|
||||
assert m["t"] == "sel" and m["from"] == 1
|
||||
assert m["block"] == "a" and m["offset"] == 1
|
||||
wb.send_json({"t": "sel", "block": None, "offset": 0})
|
||||
m2 = wa.receive_json()
|
||||
assert m2["t"] == "sel" and m2["block"] is None
|
||||
|
||||
|
||||
def test_ws_title_broadcast(client):
|
||||
pid = _make_page()
|
||||
_auth_client(client, 1, "tester")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wa:
|
||||
wa.receive_json(); wa.receive_json()
|
||||
_auth_client(client, 2, "other")
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as wb:
|
||||
wb.receive_json(); wb.receive_json()
|
||||
wa.receive_json()
|
||||
wa.send_json({"t": "title", "title": "Nouveau titre"})
|
||||
m = wb.receive_json()
|
||||
assert m["t"] == "title" and m["title"] == "Nouveau titre"
|
||||
|
||||
|
||||
# ── resynchronisation des clients périmés ──
|
||||
|
||||
def test_ws_stale_client_gets_sync(client):
|
||||
pid = _make_page(blocks=[{"id": "a", "type": "paragraph", "content": "x"}])
|
||||
_auth_client(client)
|
||||
with client.websocket_connect(f"/ws/pages/{pid}") as ws:
|
||||
ws.receive_json() # welcome
|
||||
ws.receive_json() # sync
|
||||
for i in range(3):
|
||||
ws.send_json({"t": "op", "v": 0, "op": {"type": "update",
|
||||
"block": {"id": "a", "type": "paragraph", "content": f"v{i}"}}})
|
||||
# séquence déterministe : ack(v1), ack(v2,stale)+sync(v2), ack(v3,stale)+sync(v3)
|
||||
got_stale = False
|
||||
syncs = []
|
||||
for _ in range(5):
|
||||
m = ws.receive_json()
|
||||
if m["t"] == "ack" and m.get("stale"):
|
||||
got_stale = True
|
||||
if m["t"] == "sync":
|
||||
syncs.append(m)
|
||||
assert got_stale
|
||||
assert syncs and syncs[-1]["version"] >= 3
|
||||
assert syncs[-1]["blocks"][0]["content"] == "v2"
|
||||
Reference in New Issue
Block a user