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

1from datetime import timedelta 

2from unittest.mock import patch 

3 

4import grpc 

5import pytest 

6from google.protobuf import empty_pb2 

7from sqlalchemy import select 

8 

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 

24 

25 

26@pytest.fixture(autouse=True) 

27def _(testconfig): 

28 pass 

29 

30 

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) 

37 

38 

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) 

51 

52 

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() 

57 

58 conversation_id = _create_host_request(token2, user1.id, moderator) 

59 

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 } 

69 

70 assert unseen_for[user1.id] == conversation_id 

71 assert unseen_for[outsider.id] is None 

72 

73 

74def test_mark_all_threads_seen_rejects_unspecified_category(db): 

75 _user1, token1 = generate_user() 

76 

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 

85 

86 

87def test_mark_all_threads_seen_respects_categories(db, moderator): 

88 user1, token1 = generate_user() 

89 _user2, token2 = generate_user() 

90 

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) 

94 

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 

99 

100 assert unseen() == (2, 1) 

101 

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) 

108 

109 with conversations_session(token1) as c: 

110 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

111 assert unseen() == (0, 0) 

112 

113 

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() 

118 

119 archived_chat_id = _create_group_chat(token2, [user1.id], moderator, text="archived") 

120 _create_group_chat(token3, [user1.id], moderator, text="not archived") 

121 

122 with conversations_session(token1) as c: 

123 c.SetGroupChatArchiveStatus( 

124 conversations_pb2.SetGroupChatArchiveStatusReq(group_chat_id=archived_chat_id, is_archived=True) 

125 ) 

126 

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) 

130 

131 # two messages in each chat: the creation notice and the text 

132 assert unseen_chat_messages() == 4 

133 

134 with conversations_session(token1) as c: 

135 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq(only_archived=True)) 

136 assert unseen_chat_messages() == 2 

137 

138 with conversations_session(token1) as c: 

139 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq(only_unread=True)) 

140 assert unseen_chat_messages() == 0 

141 

142 

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. 

148 

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() 

155 

156 chat_id = _create_group_chat(token2, [user1.id, user3.id], moderator) 

157 

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")) 

162 

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) 

166 

167 # chat created, "hi", and the removal notice; the three messages sent afterwards are out of reach 

168 assert unseen_chat_messages() == 3 

169 

170 with conversations_session(token1) as c: 

171 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

172 assert unseen_chat_messages() == 0 

173 

174 

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() 

183 

184 chat_id = _create_group_chat(token2, [user1.id, user3.id], moderator) 

185 

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")) 

192 

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) 

196 

197 # only the invite notice and "welcome back" are in reach; the first stint's messages are not 

198 assert unseen_chat_messages() == 2 

199 

200 with conversations_session(token1) as c: 

201 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

202 assert unseen_chat_messages() == 0 

203 

204 

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() 

212 

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 ) 

227 

228 _create_group_chat(token1, [user2.id], moderator, text="hello there") 

229 

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() 

234 

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 ] 

244 

245 assert len(unseen_missed_messages()) == 1 

246 

247 with conversations_session(token2) as c: 

248 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

249 

250 assert unseen_missed_messages() == []