- 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é
266 lines
10 KiB
Python
266 lines
10 KiB
Python
"""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" |