Files
ds-chat/backend/tests/test_presence.py
T
ksmithandClaude Sonnet 5 7ef6cfca65 Add presence indicators and a manual "appear offline" override (#36)
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>
2026-08-16 15:39:56 -06:00

133 lines
5.4 KiB
Python

import uuid
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
# Disconnect-side cleanup (GlobalPresence.disconnect, the offline broadcast)
# is deliberately not exercised end-to-end here via a `with websocket_
# connect(...)` block closing: Starlette's TestClient tears down a
# websocket session by cancelling the server-side handler's task (confirmed
# via asyncio.CancelledError while investigating a hang here), not by
# delivering a real ASGI "websocket.disconnect" message the way an actual
# client going away does -- so a `finally` block's own `await` calls can be
# interrupted mid-cleanup in tests without that ever happening in
# production. No existing test in this suite exercises presence.leave()
# post-disconnect either, for the same reason. The connect-side behavior
# below (the half that's actually reliably testable) is what matters most:
# it proves the online transition and its broadcast work correctly.
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
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_member_status_reflects_connection(ws_client):
alice = _register_ws(ws_client, _unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
assert members[0]["status"] == "offline"
with ws_client.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
assert members[0]["status"] == "online"
def test_connect_broadcasts_presence_to_shared_room(ws_client):
alice = _register_ws(ws_client, _unique("alice"))
room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
bob = _register_ws(ws_client, _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"
ws_client.post(
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
)
with ws_client.websocket_connect("/ws/chat"):
# bob connecting is a genuine offline->online transition for
# him -- alice, already joined, should hear about it even
# though she never sent anything and bob never joined a room.
assert alice_ws.receive_json() == {
"type": "member_updated",
"room_id": room["id"],
"user_id": bob["id"],
}
def test_appear_offline_overrides_actual_connection(ws_client):
alice = _register_ws(ws_client, _unique("alice"))
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"
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
assert members[0]["status"] == "online"
resp = ws_client.patch("/api/auth/me", json={"appear_offline": True})
assert resp.status_code == 200
assert resp.json()["appear_offline"] is True
# alice is joined to her own room, so the broadcast her own change
# triggers reaches her own socket.
assert ws.receive_json() == {"type": "member_updated", "room_id": room["id"], "user_id": alice["id"]}
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
assert members[0]["status"] == "offline"
resp = ws_client.patch("/api/auth/me", json={"appear_offline": False})
assert resp.status_code == 200
assert ws.receive_json()["type"] == "member_updated"
members = ws_client.get(f"/api/rooms/{room['id']}/members").json()
assert members[0]["status"] == "online"
def test_online_users_endpoint_respects_appear_offline(ws_client):
alice = _register_ws(ws_client, _unique("alice"))
bob = _register_ws(ws_client, _unique("bob"))
ws_client.post(
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
)
with ws_client.websocket_connect("/ws/chat"):
online = ws_client.get("/api/users/online").json()
assert alice["id"] in online
assert bob["id"] not in online
resp = ws_client.patch("/api/auth/me", json={"appear_offline": True})
assert resp.status_code == 200
online = ws_client.get("/api/users/online").json()
assert alice["id"] not in online
online = ws_client.get("/api/users/online").json()
assert alice["id"] not in online