Private
Public Access
Every avatar in the app (chat messages, room member list, your own avatar in the top bar/profile, the admin user list, the room-invite search) now shows a green/red presence dot. Also adds a global "Appear offline" toggle in the account menu, letting a user lurk in a room undetected -- it overrides the real connection state everywhere, not per-room. Backend: new GlobalPresence (backend/app/ws/global_presence.py), a cross-instance Redis-backed connection tracker parallel to the existing per-room Presence, incremented/decremented on WS connect/ disconnect. A new users.appear_offline column (migration f0f6e494454a) always wins over actual connection state when computing displayed status. RoomMemberRead gained a computed `status` field; add_member/change_member_role/list_room_members all compute it via a shared _member_status() helper. Connect/disconnect and profile updates (display_name, avatar, appear_offline) all broadcast member_updated to every room the user belongs to, reusing the broadcast infrastructure from the earlier avatar-staleness fix, so chat surfaces update live with no new WS envelope type needed. A new GET /api/users/online gives the admin list and user-search a snapshot (deliberately not live -- see backend/app/routers/users.py) for surfaces where "accurate as of page load" is good enough. Frontend: UserAvatar renders an optional status dot; every call site threads status/appear_offline through from whichever data source it already has (room members, the current user, or the new online-ids snapshot for admin/search). 4 new backend tests (backend/tests/test_presence.py); existing broadcast-adjacent WS tests updated to tolerate the new member_updated noise on connect. Verified end-to-end in the browser with two real users: presence dot flips live on connect/disconnect via the existing room-broadcast channel, and the lurk toggle correctly forces offline while still connected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
141 lines
5.1 KiB
Python
141 lines
5.1 KiB
Python
import uuid
|
|
|
|
from app.schemas.user import UserCreate
|
|
from app.services.auth_service import register_user
|
|
|
|
|
|
def _unique(prefix: str) -> str:
|
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
|
|
|
|
|
def _recv(ws) -> dict:
|
|
"""Reads the next frame, transparently discarding member_updated
|
|
presence-change broadcasts -- another connection in the same room going
|
|
online/offline is real, expected noise these tests aren't about."""
|
|
while True:
|
|
msg = ws.receive_json()
|
|
if msg.get("type") != "member_updated":
|
|
return msg
|
|
|
|
|
|
def _register_ws(ws_client, username: str) -> dict:
|
|
async def _seed():
|
|
async with ws_client.session_factory() as session:
|
|
await register_user(
|
|
session,
|
|
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
|
|
)
|
|
|
|
ws_client.portal.call(_seed)
|
|
resp = ws_client.post(
|
|
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
def test_ws_edit_updates_content_and_broadcasts(ws_client):
|
|
username = _unique("alice")
|
|
_register_ws(ws_client, username=username)
|
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
with ws_client.websocket_connect("/ws/chat") as ws:
|
|
ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert ws.receive_json()["type"] == "joined"
|
|
|
|
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
|
message = ws.receive_json()
|
|
assert message["edited_at"] is None
|
|
|
|
ws.send_json(
|
|
{
|
|
"type": "edit",
|
|
"room_id": room["id"],
|
|
"message_id": message["id"],
|
|
"content": "hello, edited",
|
|
}
|
|
)
|
|
update = ws.receive_json()
|
|
assert update["type"] == "message_update"
|
|
assert update["id"] == message["id"]
|
|
assert update["content"] == "hello, edited"
|
|
assert update["edited_at"] is not None
|
|
|
|
resp = ws_client.get(f"/api/rooms/{room['id']}/messages")
|
|
history = resp.json()
|
|
edited = next(m for m in history if m["id"] == message["id"])
|
|
assert edited["content"] == "hello, edited"
|
|
assert edited["edited_at"] is not None
|
|
|
|
|
|
def test_ws_edit_rejects_non_author(ws_client):
|
|
alice = _register_ws(ws_client, username=_unique("alice"))
|
|
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(ws_client, username=_unique("bob"))
|
|
ws_client.post(f"/api/rooms/{room['id']}/join")
|
|
|
|
ws_client.post(
|
|
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
|
|
)
|
|
with ws_client.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
|
|
message = alice_ws.receive_json()
|
|
|
|
ws_client.post(
|
|
"/api/auth/login",
|
|
json={"username_or_email": bob["username"], "password": "password123"},
|
|
)
|
|
with ws_client.websocket_connect("/ws/chat") as bob_ws:
|
|
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert bob_ws.receive_json()["type"] == "joined"
|
|
bob_ws.send_json(
|
|
{
|
|
"type": "edit",
|
|
"room_id": room["id"],
|
|
"message_id": message["id"],
|
|
"content": "hacked",
|
|
}
|
|
)
|
|
resp = bob_ws.receive_json()
|
|
assert resp["type"] == "error"
|
|
assert "own messages" in resp["detail"]
|
|
|
|
|
|
def test_edit_fans_out_across_instances(ws_client_factory):
|
|
instance1 = ws_client_factory()
|
|
instance2 = ws_client_factory()
|
|
|
|
alice = _register_ws(instance1, _unique("alice"))
|
|
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
|
|
|
|
bob = _register_ws(instance2, _unique("bob"))
|
|
instance2.post(f"/api/rooms/{room['id']}/join")
|
|
|
|
with instance2.websocket_connect("/ws/chat") as bob_ws:
|
|
bob_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert bob_ws.receive_json()["type"] == "joined"
|
|
|
|
with instance1.websocket_connect("/ws/chat") as alice_ws:
|
|
alice_ws.send_json({"type": "join", "room_id": room["id"]})
|
|
assert alice_ws.receive_json()["type"] == "joined"
|
|
alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
|
|
message = alice_ws.receive_json()
|
|
assert _recv(bob_ws)["type"] == "message"
|
|
|
|
alice_ws.send_json(
|
|
{
|
|
"type": "edit",
|
|
"room_id": room["id"],
|
|
"message_id": message["id"],
|
|
"content": "hi, edited",
|
|
}
|
|
)
|
|
assert alice_ws.receive_json()["type"] == "message_update"
|
|
|
|
update = _recv(bob_ws)
|
|
assert update["type"] == "message_update"
|
|
assert update["content"] == "hi, edited"
|