Files
ds-chat/backend/tests/test_push.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

221 lines
8.6 KiB
Python

import uuid
from pywebpush import WebPushException
from sqlalchemy import select
from app.models import PushSubscription
from tests.conftest import login_as, register_and_login
def _subscription_payload(suffix: str = "a") -> dict:
return {
"endpoint": f"https://push.example.com/ep-{suffix}",
"keys": {"p256dh": f"p256dh-{suffix}", "auth": f"auth-{suffix}"},
}
async def test_vapid_public_key_endpoint(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.get("/api/push/vapid-public-key")
assert resp.status_code == 200
assert "public_key" in resp.json()
def _unique_suffix() -> str:
return uuid.uuid4().hex[:8]
async def test_subscribe_creates_row(client, db_session):
await register_and_login(client, db_session, username="alice")
payload = _subscription_payload(_unique_suffix())
resp = await client.post("/api/push/subscribe", json=payload)
assert resp.status_code == 204
# The ds_chat_test database is shared across the whole suite and the
# ws_client-based tests below intentionally don't roll back (see
# conftest.ws_client), so a unique endpoint keeps this test independent
# of leftover rows from those instead of asserting on the total count.
result = await db_session.execute(
select(PushSubscription).where(PushSubscription.endpoint == payload["endpoint"])
)
rows = result.scalars().all()
assert len(rows) == 1
async def test_subscribe_upserts_by_endpoint(client, db_session):
await register_and_login(client, db_session, username="alice")
payload = _subscription_payload(_unique_suffix())
assert (await client.post("/api/push/subscribe", json=payload)).status_code == 204
updated = {**payload, "keys": {"p256dh": "new-p256dh", "auth": "new-auth"}}
assert (await client.post("/api/push/subscribe", json=updated)).status_code == 204
result = await db_session.execute(
select(PushSubscription).where(PushSubscription.endpoint == payload["endpoint"])
)
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].p256dh_key == "new-p256dh"
async def test_unsubscribe_removes_row(client, db_session):
await register_and_login(client, db_session, username="alice")
payload = _subscription_payload(_unique_suffix())
await client.post("/api/push/subscribe", json=payload)
resp = await client.request(
"DELETE", "/api/push/subscribe", json={"endpoint": payload["endpoint"]}
)
assert resp.status_code == 204
result = await db_session.execute(
select(PushSubscription).where(PushSubscription.endpoint == payload["endpoint"])
)
assert result.scalars().all() == []
def _register_ws(ws_client, username: str) -> dict:
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
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 _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 _fetch_subscriptions(ws_client, user_id: str) -> list[PushSubscription]:
async def _query():
async with ws_client.session_factory() as session:
result = await session.execute(
select(PushSubscription).where(PushSubscription.user_id == uuid.UUID(user_id))
)
return list(result.scalars().all())
return ws_client.portal.call(_query)
def test_ws_message_pushes_offline_member_only(ws_client, monkeypatch):
calls = []
def fake_webpush(**kwargs):
calls.append(kwargs)
monkeypatch.setattr("app.services.push_service.webpush", fake_webpush)
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/push/subscribe", json=_subscription_payload(_unique("bob")))
login_resp = ws_client.post(
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
)
assert login_resp.status_code == 200
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"})
assert ws.receive_json()["type"] == "message"
# The handler processes frames strictly sequentially, so a second
# (idempotent) join only gets acked once the "message" frame's full
# handling -- including the offline-push step -- has completed. A
# plain `with` block exit doesn't guarantee that: closing can race
# ahead of (and cancel) still-in-flight server-side work.
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
assert len(calls) == 1
assert calls[0]["subscription_info"]["endpoint"].startswith("https://push.example.com/ep-bob")
assert "hello" in calls[0]["data"]
assert alice["username"] in calls[0]["data"] # sender attribution in the payload
def test_ws_message_no_push_when_member_connected(ws_client, monkeypatch):
calls = []
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
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/push/subscribe", json=_subscription_payload(_unique("bob")))
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"
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": "hi"})
assert alice_ws.receive_json()["type"] == "message"
# bob is connected too -- he should get the broadcast, not a push
assert _recv(bob_ws)["type"] == "message"
assert calls == []
def test_expired_subscription_is_cleaned_up(ws_client, monkeypatch):
class FakeResponse:
status_code = 410
def fake_webpush(**kwargs):
raise WebPushException("gone", response=FakeResponse())
monkeypatch.setattr("app.services.push_service.webpush", fake_webpush)
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/push/subscribe", json=_subscription_payload(_unique("bob")))
assert len(_fetch_subscriptions(ws_client, bob["id"])) == 1
ws_client.post(
"/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
)
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"})
assert ws.receive_json()["type"] == "message"
# See test_ws_message_pushes_offline_member_only for why this sync
# barrier is needed before checking server-side push side effects.
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
assert _fetch_subscriptions(ws_client, bob["id"]) == []