Files
ds-chat/backend/tests/test_desktop_notifications.py
ksmithandClaude Sonnet 5 ed88eb0205 Fix desktop messages not appearing live until refocus (#59)
Desktop mode's focus gating (from #49) made losing OS focus send "leave"
for every open room, which stopped live message delivery to that room,
not just notification eligibility -- so a message wouldn't render until
the room was manually left and rejoined. Room join/leave is now gated on
visibility alone, matching the browser; notification eligibility gets its
own separate signal (a "focus"/"blur" WS frame tracked by a new
Redis-backed FocusPresence), so a connected-but-unfocused desktop member
still gets notified without losing live delivery.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 18:08:04 -06:00

238 lines
10 KiB
Python

import uuid
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _recv(ws) -> dict:
"""Reads the next frame, discarding member_updated presence-change
broadcasts -- same convention as test_mentions.py/test_push.py."""
while True:
msg = ws.receive_json()
if msg.get("type") != "member_updated":
return msg
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 _send_and_sync(ws, room_id: str, content: str) -> dict:
"""Sync barrier -- see test_mentions.py's identical helper. Proves the
message frame's full handling (including the offline-notify step this
feature hooks into) has completed before the test checks anything."""
ws.send_json({"type": "message", "room_id": room_id, "content": content})
message = ws.receive_json()
ws.send_json({"type": "join", "room_id": room_id})
assert ws.receive_json()["type"] == "joined"
return message
def test_desktop_notification_delivered_to_offline_member(ws_client_factory):
# #49: bob has an open connection (so he can receive his per-user
# channel broadcast) but hasn't joined *this* room's channel -- exactly
# the "app running, room not foregrounded" case _notify_offline_members
# already treats as offline for push, and desktop notifications should
# use the identical audience.
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
_register_ws(instance2, _unique("bob"))
instance2.post(f"/api/rooms/{room['id']}/join")
with instance2.websocket_connect("/ws/chat") as bob_ws:
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"
message = _send_and_sync(alice_ws, room["id"], "hey, look at this")
assert message["type"] == "message"
# bob never joined the room's channel on his connection, so he's
# "offline" for it even though instance2 is connected.
bob_update = _recv(bob_ws)
assert bob_update["type"] == "unread_update"
desktop_note = _recv(bob_ws)
assert desktop_note == {
"type": "desktop_notification",
"id": message["id"],
"room_id": room["id"],
"title": f"#{room['name']}",
"body": f"{alice['username']}: hey, look at this",
}
def test_desktop_notification_uses_mention_wording(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:
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"
_send_and_sync(alice_ws, room["id"], f"@{bob['username']} check this out")
_recv(bob_ws) # unread_update
desktop_note = _recv(bob_ws)
assert desktop_note["type"] == "desktop_notification"
assert desktop_note["body"] == f"{alice['username']} mentioned you: @{bob['username']} check this out"
def test_desktop_notification_not_sent_to_room_member_who_is_present(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()
_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"
_send_and_sync(alice_ws, room["id"], "hello")
# bob is actively in the room -- he should only see the live
# "message" broadcast, never an unread_update or desktop_notification.
live_message = _recv(bob_ws)
assert live_message["type"] == "message"
def test_desktop_notification_sent_to_connected_but_blurred_member(ws_client_factory):
# #59: bob keeps the room's channel joined (so live delivery to a room
# actually open on screen never stops) but reports his desktop window
# as unfocused via a "focus" frame -- the whole point of this fix is
# that notification eligibility no longer needs the client to fake
# "offline" by leaving the room's channel, which used to also break
# live delivery until the room was manually left and rejoined.
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
_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"
bob_ws.send_json({"type": "focus", "focused": False})
# Sync barrier -- see test_mentions.py's identical pattern: a
# second (idempotent) join only acks once the prior "focus"
# frame's own handling (and commit) has completed.
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"
message = _send_and_sync(alice_ws, room["id"], "you awake?")
assert message["type"] == "message"
# Live delivery still works -- bob's room channel was never left.
live_message = _recv(bob_ws)
assert live_message["type"] == "message"
assert live_message["content"] == "you awake?"
# ...and he's still notified, despite being "connected" to the room.
desktop_note = _recv(bob_ws)
assert desktop_note == {
"type": "desktop_notification",
"id": message["id"],
"room_id": room["id"],
"title": f"#{room['name']}",
"body": f"{alice['username']}: you awake?",
}
def test_desktop_notification_not_sent_after_refocus(ws_client_factory):
# Proves the "focus" signal is a live toggle, not one-way -- blurring
# and then refocusing before the message arrives must fully cancel out.
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
_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"
bob_ws.send_json({"type": "focus", "focused": False})
bob_ws.send_json({"type": "focus", "focused": True})
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"
_send_and_sync(alice_ws, room["id"], "hello again")
# Refocused before the message arrived -- only the live broadcast,
# same as test_desktop_notification_not_sent_to_room_member_who_is_present.
live_message = _recv(bob_ws)
assert live_message["type"] == "message"
def test_desktop_notification_not_sent_to_non_member(ws_client_factory):
instance1 = ws_client_factory()
instance2 = ws_client_factory()
_register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
_register_ws(instance2, _unique("outsider"))
# Deliberately not joining `room`.
with instance2.websocket_connect("/ws/chat") as outsider_ws:
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"
_send_and_sync(alice_ws, room["id"], "hello")
# Nothing should ever arrive on the outsider's own channel for a
# room they aren't a member of. Send a harmless self-targeted
# frame on a *different* room-less action and confirm the socket
# stays quiet: simplest proof is a short, bounded wait via a
# room creation (which touches no broadcast) -- if anything queued
# up for outsider, it would already be sitting in the socket buffer.
room2 = instance2.post("/api/rooms", json={"name": _unique("outsiders-room")}).json()
outsider_ws.send_json({"type": "join", "room_id": room2["id"]})
joined = outsider_ws.receive_json()
assert joined == {"type": "joined", "room_id": room2["id"]}