Private
Public Access
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>
238 lines
10 KiB
Python
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"]}
|