Coverage for app/backend/src/tests/test_message_threads.py: 100%
129 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 23:56 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 23:56 +0000
1from datetime import timedelta
2from unittest.mock import patch
4import grpc
5import pytest
6from google.protobuf import empty_pb2
7from sqlalchemy import select
9from couchers.db import session_scope
10from couchers.helpers.host_requests import has_unseen_host_request_messages
11from couchers.jobs.handlers import send_message_notifications
12from couchers.models import HostRequest, NotificationTopicAction
13from couchers.proto import api_pb2, conversations_pb2, notifications_pb2, requests_pb2
14from couchers.utils import today
15from tests.fixtures.db import generate_user
16from tests.fixtures.misc import now_5_min_in_future, process_jobs
17from tests.fixtures.sessions import (
18 conversations_session,
19 notifications_session,
20 real_api_session,
21 requests_session,
22)
23from tests.test_requests import valid_request_text
26@pytest.fixture(autouse=True)
27def _(testconfig):
28 pass
31def _create_group_chat(token: str, recipient_ids: list[int], moderator, text: str = "hi") -> int:
32 with conversations_session(token) as c:
33 res = c.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=recipient_ids))
34 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=res.group_chat_id, text=text))
35 moderator.approve_group_chat(res.group_chat_id)
36 return int(res.group_chat_id)
39def _create_host_request(surfer_token: str, host_id: int, moderator) -> int:
40 with requests_session(surfer_token) as api:
41 res = api.CreateHostRequest(
42 requests_pb2.CreateHostRequestReq(
43 host_user_id=host_id,
44 from_date=(today() + timedelta(days=5)).isoformat(),
45 to_date=(today() + timedelta(days=10)).isoformat(),
46 text=valid_request_text(),
47 )
48 )
49 moderator.approve_host_request(res.host_request_id)
50 return int(res.host_request_id)
53def test_has_unseen_host_request_messages_is_false_for_a_non_party(db, moderator):
54 user1, _token1 = generate_user()
55 _user2, token2 = generate_user()
56 outsider, _token3 = generate_user()
58 conversation_id = _create_host_request(token2, user1.id, moderator)
60 with session_scope() as session:
61 unseen_for = {
62 user_id: session.execute(
63 select(HostRequest.conversation_id)
64 .where(HostRequest.conversation_id == conversation_id)
65 .where(has_unseen_host_request_messages(user_id))
66 ).scalar_one_or_none()
67 for user_id in (user1.id, outsider.id)
68 }
70 assert unseen_for[user1.id] == conversation_id
71 assert unseen_for[outsider.id] is None
74def test_mark_all_threads_seen_rejects_unspecified_category(db):
75 _user1, token1 = generate_user()
77 with conversations_session(token1) as c:
78 with pytest.raises(grpc.RpcError) as e:
79 c.MarkAllThreadsSeen(
80 conversations_pb2.MarkAllThreadsSeenReq(
81 categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_UNSPECIFIED]
82 )
83 )
84 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
87def test_mark_all_threads_seen_respects_categories(db, moderator):
88 user1, token1 = generate_user()
89 _user2, token2 = generate_user()
91 # an unread group chat and an unread host request, both from user2
92 _create_group_chat(token2, [user1.id], moderator, text="hello there")
93 _create_host_request(token2, user1.id, moderator)
95 def unseen() -> tuple[int, int]:
96 with real_api_session(token1) as api:
97 res = api.Ping(api_pb2.PingReq())
98 return res.unseen_message_count, res.unseen_received_host_request_count
100 assert unseen() == (2, 1)
102 with conversations_session(token1) as c:
103 c.MarkAllThreadsSeen(
104 conversations_pb2.MarkAllThreadsSeenReq(categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_CHATS])
105 )
106 # the chat is now read; the host request is untouched
107 assert unseen() == (0, 1)
109 with conversations_session(token1) as c:
110 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq())
111 assert unseen() == (0, 0)
114def test_mark_all_threads_seen_respects_unread_and_archived_filters(db, moderator):
115 user1, token1 = generate_user()
116 _user2, token2 = generate_user()
117 _user3, token3 = generate_user()
119 archived_chat_id = _create_group_chat(token2, [user1.id], moderator, text="archived")
120 _create_group_chat(token3, [user1.id], moderator, text="not archived")
122 with conversations_session(token1) as c:
123 c.SetGroupChatArchiveStatus(
124 conversations_pb2.SetGroupChatArchiveStatusReq(group_chat_id=archived_chat_id, is_archived=True)
125 )
127 def unseen_chat_messages() -> int:
128 with real_api_session(token1) as api:
129 return int(api.Ping(api_pb2.PingReq()).unseen_message_count)
131 # two messages in each chat: the creation notice and the text
132 assert unseen_chat_messages() == 4
134 with conversations_session(token1) as c:
135 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq(only_archived=True))
136 assert unseen_chat_messages() == 2
138 with conversations_session(token1) as c:
139 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq(only_unread=True))
140 assert unseen_chat_messages() == 0
143def test_mark_all_threads_seen_clears_departed_group_chat(db, moderator):
144 """
145 A viewer who was removed from a chat can only reach the messages sent before they left, so
146 marking everything seen has to advance them to that message rather than to the chat's newest —
147 otherwise the badge never clears.
149 The viewer is removed rather than leaving, because leaving posts a message of your own, which
150 marks everything up to it seen.
151 """
152 user1, token1 = generate_user()
153 _user2, token2 = generate_user()
154 user3, _token3 = generate_user()
156 chat_id = _create_group_chat(token2, [user1.id, user3.id], moderator)
158 with conversations_session(token2) as c:
159 c.RemoveGroupChatUser(conversations_pb2.RemoveGroupChatUserReq(group_chat_id=chat_id, user_id=user1.id))
160 for _ in range(3):
161 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=chat_id, text="after you left"))
163 def unseen_chat_messages() -> int:
164 with real_api_session(token1) as api:
165 return int(api.Ping(api_pb2.PingReq()).unseen_message_count)
167 # chat created, "hi", and the removal notice; the three messages sent afterwards are out of reach
168 assert unseen_chat_messages() == 3
170 with conversations_session(token1) as c:
171 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq())
172 assert unseen_chat_messages() == 0
175def test_mark_all_threads_seen_advances_the_current_subscription(db, moderator):
176 """
177 Rejoining a chat leaves the earlier subscription behind with its own last-seen state. Only the
178 current subscription is advanced, and the stale one must not hold the chat unread afterwards.
179 """
180 user1, token1 = generate_user()
181 _user2, token2 = generate_user()
182 user3, _token3 = generate_user()
184 chat_id = _create_group_chat(token2, [user1.id, user3.id], moderator)
186 # removed rather than leaving, so user1's first subscription is left behind with unread messages
187 with conversations_session(token2) as c:
188 c.RemoveGroupChatUser(conversations_pb2.RemoveGroupChatUserReq(group_chat_id=chat_id, user_id=user1.id))
189 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=chat_id, text="while you were away"))
190 c.InviteToGroupChat(conversations_pb2.InviteToGroupChatReq(group_chat_id=chat_id, user_id=user1.id))
191 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=chat_id, text="welcome back"))
193 def unseen_chat_messages() -> int:
194 with real_api_session(token1) as api:
195 return int(api.Ping(api_pb2.PingReq()).unseen_message_count)
197 # only the invite notice and "welcome back" are in reach; the first stint's messages are not
198 assert unseen_chat_messages() == 2
200 with conversations_session(token1) as c:
201 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq())
202 assert unseen_chat_messages() == 0
205def test_mark_all_threads_seen_clears_missed_messages_notification(db, moderator):
206 """
207 Regression test: chat__missed_messages is a summary keyed with "" rather than a chat id, so it
208 needs its own (topic actions, keys) group instead of being pooled with the per-chat keys.
209 """
210 user1, token1 = generate_user()
211 user2, token2 = generate_user()
213 # this notification is email-only by default, and the in-app feed only shows push-enabled ones
214 with notifications_session(token2) as n:
215 n.SetNotificationSettings(
216 notifications_pb2.SetNotificationSettingsReq(
217 preferences=[
218 notifications_pb2.SingleNotificationPreference(
219 topic=NotificationTopicAction.chat__missed_messages.topic,
220 action=NotificationTopicAction.chat__missed_messages.action,
221 delivery_method="push",
222 enabled=True,
223 )
224 ]
225 )
226 )
228 _create_group_chat(token1, [user2.id], moderator, text="hello there")
230 # the job only picks up messages that have been unseen for five minutes
231 with patch("couchers.jobs.handlers.now", now_5_min_in_future):
232 send_message_notifications(empty_pb2.Empty())
233 process_jobs()
235 def unseen_missed_messages():
236 with notifications_session(token2) as n:
237 res = n.ListNotifications(notifications_pb2.ListNotificationsReq(only_unread=True))
238 return [
239 notification
240 for notification in res.notifications
241 if notification.topic == NotificationTopicAction.chat__missed_messages.topic
242 and notification.action == NotificationTopicAction.chat__missed_messages.action
243 ]
245 assert len(unseen_missed_messages()) == 1
247 with conversations_session(token2) as c:
248 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq())
250 assert unseen_missed_messages() == []