Coverage for app/backend/src/tests/test_message_threads.py: 100%

324 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 15:29 +0000

1from datetime import timedelta 

2 

3import grpc 

4import pytest 

5from google.protobuf import empty_pb2 

6from sqlalchemy import select 

7 

8from couchers.constants import MISSED_MESSAGES_DELAY 

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, User 

13from couchers.proto import api_pb2, conversations_pb2, messages_pb2, notifications_pb2, requests_pb2 

14from couchers.utils import today 

15from tests.fixtures.db import generate_user, make_user_block 

16from tests.fixtures.misc import process_jobs 

17from tests.fixtures.sessions import ( 

18 conversations_session, 

19 notifications_session, 

20 real_api_session, 

21 requests_session, 

22) 

23from tests.fixtures.timewarp import Timewarp 

24from tests.test_communities import create_community 

25from tests.test_public_trips import _create_trip_directly 

26from tests.test_requests import valid_request_text 

27 

28 

29def _make_trip(user: User) -> tuple[int, int]: 

30 """Create a community + an active public trip for the given traveller.""" 

31 with session_scope() as session: 

32 node_id = create_community(session, 0, 2, "Test community", [user], [], None).id 

33 trip_id = _create_trip_directly(user.id, node_id, today() + timedelta(days=5), today() + timedelta(days=10)) 

34 return node_id, trip_id 

35 

36 

37def _create_group_chat(token: str, recipient_ids: list[int], moderator, text: str = "hi") -> int: 

38 with conversations_session(token) as c: 

39 res = c.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=recipient_ids)) 

40 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=res.group_chat_id, text=text)) 

41 moderator.approve_group_chat(res.group_chat_id) 

42 return int(res.group_chat_id) 

43 

44 

45def _create_host_request(surfer_token: str, host_id: int, moderator, public_trip_id: int | None = None) -> int: 

46 with requests_session(surfer_token) as api: 

47 res = api.CreateHostRequest( 

48 requests_pb2.CreateHostRequestReq( 

49 host_user_id=host_id, 

50 from_date=(today() + timedelta(days=5)).isoformat(), 

51 to_date=(today() + timedelta(days=10)).isoformat(), 

52 text=valid_request_text(), 

53 public_trip_id=public_trip_id, 

54 ) 

55 ) 

56 moderator.approve_host_request(res.host_request_id) 

57 return int(res.host_request_id) 

58 

59 

60def test_has_unseen_host_request_messages_is_false_for_a_non_party(db, moderator): 

61 user1, _token1 = generate_user() 

62 _user2, token2 = generate_user() 

63 outsider, _token3 = generate_user() 

64 

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

66 

67 with session_scope() as session: 

68 unseen_for = { 

69 user_id: session.execute( 

70 select(HostRequest.conversation_id) 

71 .where(HostRequest.conversation_id == conversation_id) 

72 .where(has_unseen_host_request_messages(user_id)) 

73 ).scalar_one_or_none() 

74 for user_id in (user1.id, outsider.id) 

75 } 

76 

77 assert unseen_for[user1.id] == conversation_id 

78 assert unseen_for[outsider.id] is None 

79 

80 

81def test_list_message_threads_latest_status_change_message(db, moderator): 

82 # Regression: a thread whose latest message is a host-request status change 

83 # must serialize with its content set (not an empty control message). 

84 user1, token1 = generate_user() 

85 user2, token2 = generate_user() 

86 

87 request_id = _create_host_request(token2, user1.id, moderator) 

88 

89 # user1 (the host) accepts, so the latest message becomes a status change 

90 with requests_session(token1) as api: 

91 api.RespondHostRequest( 

92 requests_pb2.RespondHostRequestReq( 

93 host_request_id=request_id, 

94 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

95 text="", 

96 ) 

97 ) 

98 

99 with conversations_session(token1) as c: 

100 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq()) 

101 thread = next(t for t in res.threads if t.WhichOneof("thread") == "host_request") 

102 assert thread.host_request.latest_message.WhichOneof("content") == "host_request_status_changed" 

103 assert ( 

104 thread.host_request.latest_message.host_request_status_changed.status 

105 == messages_pb2.HOST_REQUEST_STATUS_ACCEPTED 

106 ) 

107 

108 

109def test_list_message_threads_need_host_request_feedback(db, moderator): 

110 user1, token1 = generate_user() 

111 user2, token2 = generate_user() 

112 

113 request_id = _create_host_request(token2, user1.id, moderator) 

114 

115 def host_request_thread(token: str) -> requests_pb2.HostRequest: 

116 with conversations_session(token) as c: 

117 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq()) 

118 thread: requests_pb2.HostRequest = next( 

119 t.host_request for t in res.threads if t.host_request.host_request_id == request_id 

120 ) 

121 return thread 

122 

123 assert not host_request_thread(token1).need_host_request_feedback 

124 

125 with requests_session(token1) as api: 

126 api.RespondHostRequest( 

127 requests_pb2.RespondHostRequestReq( 

128 host_request_id=request_id, 

129 status=messages_pb2.HOST_REQUEST_STATUS_REJECTED, 

130 ) 

131 ) 

132 

133 assert host_request_thread(token1).need_host_request_feedback 

134 # only the host is asked for feedback 

135 assert not host_request_thread(token2).need_host_request_feedback 

136 

137 with requests_session(token1) as api: 

138 api.SendHostRequestFeedback( 

139 requests_pb2.SendHostRequestFeedbackReq( 

140 host_request_id=request_id, 

141 host_request_quality=requests_pb2.HOST_REQUEST_QUALITY_LOW, 

142 ) 

143 ) 

144 

145 assert not host_request_thread(token1).need_host_request_feedback 

146 

147 

148def test_list_message_threads_can_message(db, moderator): 

149 user1, token1 = generate_user() 

150 user2, token2 = generate_user() 

151 user3, token3 = generate_user() 

152 

153 blocked_dm_id = _create_group_chat(token1, [user2.id], moderator) 

154 departed_dm_id = _create_group_chat(token1, [user3.id], moderator) 

155 chat_id = _create_group_chat(token1, [user2.id, user3.id], moderator) 

156 

157 def can_message_by_chat(token: str) -> dict[int, bool]: 

158 with conversations_session(token) as c: 

159 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq()) 

160 return {t.group_chat.group_chat_id: t.group_chat.can_message for t in res.threads} 

161 

162 assert can_message_by_chat(token1) == {blocked_dm_id: True, departed_dm_id: True, chat_id: True} 

163 

164 make_user_block(user2, user1) 

165 with conversations_session(token3) as c: 

166 c.LeaveGroupChat(conversations_pb2.LeaveGroupChatReq(group_chat_id=departed_dm_id)) 

167 

168 # a DM whose other party is blocked or gone can't be messaged; a true group chat always can 

169 assert can_message_by_chat(token1) == {blocked_dm_id: False, departed_dm_id: False, chat_id: True} 

170 

171 

172def test_list_message_threads_interleaves_chats_and_requests(db, moderator): 

173 user1, token1 = generate_user() 

174 user2, token2 = generate_user() 

175 

176 chat_id = _create_group_chat(token1, [user2.id], moderator) 

177 # user2 sends a host request to user1 (user1 is the host) 

178 request_id = _create_host_request(token2, user1.id, moderator) 

179 

180 with conversations_session(token1) as c: 

181 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq()) 

182 

183 kinds = [t.WhichOneof("thread") for t in res.threads] 

184 assert "group_chat" in kinds 

185 assert "host_request" in kinds 

186 ids = { 

187 (t.group_chat.group_chat_id if t.WhichOneof("thread") == "group_chat" else t.host_request.host_request_id) 

188 for t in res.threads 

189 } 

190 assert ids == {chat_id, request_id} 

191 # The host request was created after the group chat, so it sorts first (latest message). 

192 assert res.threads[0].WhichOneof("thread") == "host_request" 

193 

194 

195def test_list_message_threads_single_cursor_pagination_across_kinds(db, moderator): 

196 user1, token1 = generate_user() 

197 others = [generate_user() for _ in range(6)] 

198 

199 # track the two kinds separately so we verify each id comes back as the right kind 

200 expected_chat_ids = set() 

201 expected_request_ids = set() 

202 # interleave creating group chats and host requests so both kinds straddle page boundaries 

203 for other, other_token in others: 

204 expected_chat_ids.add(_create_group_chat(token1, [other.id], moderator)) 

205 expected_request_ids.add(_create_host_request(other_token, user1.id, moderator)) 

206 

207 collected_chat_ids: list[int] = [] 

208 collected_request_ids: list[int] = [] 

209 latest_ids: list[int] = [] 

210 page_token = "" 

211 while True: 

212 with conversations_session(token1) as c: 

213 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq(page_size=3, page_token=page_token)) 

214 for t in res.threads: 

215 if t.WhichOneof("thread") == "group_chat": 

216 collected_chat_ids.append(t.group_chat.group_chat_id) 

217 latest_ids.append(t.group_chat.latest_message.message_id) 

218 else: 

219 collected_request_ids.append(t.host_request.host_request_id) 

220 latest_ids.append(t.host_request.latest_message.message_id) 

221 if not res.next_page_token: 

222 break 

223 page_token = res.next_page_token 

224 

225 # every thread appears exactly once as its correct kind, none missing or duplicated 

226 assert sorted(collected_chat_ids) == sorted(expected_chat_ids) 

227 assert sorted(collected_request_ids) == sorted(expected_request_ids) 

228 assert len(collected_chat_ids) == len(set(collected_chat_ids)) 

229 assert len(collected_request_ids) == len(set(collected_request_ids)) 

230 # globally ordered by latest message id, descending, with no straddling across pages 

231 assert latest_ids == sorted(latest_ids, reverse=True) 

232 

233 

234def test_list_message_threads_chats_filter_excludes_host_requests(db, moderator): 

235 user1, token1 = generate_user() 

236 user2, token2 = generate_user() 

237 

238 chat_id = _create_group_chat(token1, [user2.id], moderator) 

239 _create_host_request(token2, user1.id, moderator) 

240 

241 with conversations_session(token1) as c: 

242 res = c.ListMessageThreads( 

243 conversations_pb2.ListMessageThreadsReq(categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_CHATS]) 

244 ) 

245 

246 assert [t.WhichOneof("thread") for t in res.threads] == ["group_chat"] 

247 assert res.threads[0].group_chat.group_chat_id == chat_id 

248 

249 

250def test_list_message_threads_roster_is_frozen_for_a_departed_viewer(db, moderator): 

251 """ 

252 A viewer who was removed from a chat sees its roster as it was when they left, so anyone added 

253 afterwards is invisible to them. 

254 """ 

255 admin, admin_token = generate_user() 

256 viewer, viewer_token = generate_user() 

257 member, _member_token = generate_user() 

258 latecomer, _latecomer_token = generate_user() 

259 

260 chat_id = _create_group_chat(admin_token, [viewer.id, member.id], moderator) 

261 

262 with conversations_session(admin_token) as c: 

263 c.RemoveGroupChatUser(conversations_pb2.RemoveGroupChatUserReq(group_chat_id=chat_id, user_id=viewer.id)) 

264 c.InviteToGroupChat(conversations_pb2.InviteToGroupChatReq(group_chat_id=chat_id, user_id=latecomer.id)) 

265 

266 with conversations_session(viewer_token) as c: 

267 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq()) 

268 departed_view = res.threads[0].group_chat 

269 assert set(departed_view.member_user_ids) == {admin.id, viewer.id, member.id} 

270 assert list(departed_view.admin_user_ids) == [admin.id] 

271 # chat created, "hi" and the removal notice; the invite notice is out of reach 

272 assert departed_view.unseen_message_count == 3 

273 

274 with conversations_session(admin_token) as c: 

275 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq()) 

276 admin_view = res.threads[0].group_chat 

277 assert set(admin_view.member_user_ids) == {admin.id, member.id, latecomer.id} 

278 

279 

280def test_list_message_threads_rejects_unspecified_category(db): 

281 _user1, token1 = generate_user() 

282 

283 with conversations_session(token1) as c: 

284 with pytest.raises(grpc.RpcError) as e: 

285 c.ListMessageThreads( 

286 conversations_pb2.ListMessageThreadsReq( 

287 categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_UNSPECIFIED] 

288 ) 

289 ) 

290 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

291 

292 

293def test_list_message_threads_unread_filter(db, moderator): 

294 user1, token1 = generate_user() 

295 user2, token2 = generate_user() 

296 

297 # user2 sends a request to user1 -> user1 has unseen messages 

298 request_id = _create_host_request(token2, user1.id, moderator) 

299 

300 with conversations_session(token1) as c: 

301 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq(only_unread=True)) 

302 assert [t.host_request.host_request_id for t in res.threads] == [request_id] 

303 

304 # after marking everything seen, the unread filter is empty 

305 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

306 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq(only_unread=True)) 

307 assert len(res.threads) == 0 

308 

309 

310def test_list_message_threads_archived_is_orthogonal(db, moderator): 

311 user1, token1 = generate_user() 

312 user2, token2 = generate_user() 

313 

314 chat_id = _create_group_chat(token1, [user2.id], moderator) 

315 

316 with conversations_session(token1) as c: 

317 # archive the chat 

318 c.SetGroupChatArchiveStatus( 

319 conversations_pb2.SetGroupChatArchiveStatusReq(group_chat_id=chat_id, is_archived=True) 

320 ) 

321 

322 # default (non-archived) excludes it 

323 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq(only_archived=False)) 

324 assert len(res.threads) == 0 

325 

326 # only_archived=True includes it 

327 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq(only_archived=True)) 

328 assert [t.group_chat.group_chat_id for t in res.threads] == [chat_id] 

329 

330 

331def test_list_message_threads_public_trip_offer_role_based(db, moderator): 

332 traveler, traveler_token = generate_user() 

333 host, host_token = generate_user() 

334 _, trip_id = _make_trip(traveler) 

335 

336 # host offers to host the traveller's public trip (role reversal) 

337 request_id = _create_host_request(host_token, traveler.id, moderator, public_trip_id=trip_id) 

338 

339 # From the offering host's view: appears under HOSTING (role-based filter) 

340 with conversations_session(host_token) as c: 

341 res = c.ListMessageThreads( 

342 conversations_pb2.ListMessageThreadsReq(categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_HOSTING]) 

343 ) 

344 assert len(res.threads) == 1 

345 hr = res.threads[0].host_request 

346 assert hr.host_request_id == request_id 

347 assert hr.HasField("public_trip_id") 

348 assert hr.public_trip_id == trip_id 

349 # payload keeps the Requests API semantics: surfer/host are the stay roles, which an 

350 # offer reverses — the offering host is the host, the trip's traveller is the surfer 

351 assert hr.surfer_user_id == traveler.id 

352 assert hr.host_user_id == host.id 

353 

354 # not under SURFING for the host 

355 res = c.ListMessageThreads( 

356 conversations_pb2.ListMessageThreadsReq(categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_SURFING]) 

357 ) 

358 assert len(res.threads) == 0 

359 

360 # the same request served by the Requests API has to agree on who's who 

361 with requests_session(host_token) as api: 

362 from_requests_api = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=request_id)) 

363 assert (from_requests_api.surfer_user_id, from_requests_api.host_user_id) == (hr.surfer_user_id, hr.host_user_id) 

364 

365 # From the traveller's view: appears under SURFING and MY_PUBLIC_TRIPS (role-based filters) 

366 with conversations_session(traveler_token) as c: 

367 res = c.ListMessageThreads( 

368 conversations_pb2.ListMessageThreadsReq(categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_SURFING]) 

369 ) 

370 assert [t.host_request.host_request_id for t in res.threads] == [request_id] 

371 # same payload regardless of viewer 

372 assert res.threads[0].host_request.surfer_user_id == traveler.id 

373 assert res.threads[0].host_request.host_user_id == host.id 

374 

375 res = c.ListMessageThreads( 

376 conversations_pb2.ListMessageThreadsReq( 

377 categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_MY_PUBLIC_TRIPS] 

378 ) 

379 ) 

380 assert [t.host_request.host_request_id for t in res.threads] == [request_id] 

381 

382 # also present in ALL 

383 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq()) 

384 assert request_id in {t.host_request.host_request_id for t in res.threads} 

385 

386 

387def test_list_message_threads_public_trips_filter_gated_by_flag(db, moderator, feature_flags): 

388 feature_flags.set("public_trips_enabled", False) 

389 

390 traveler, traveler_token = generate_user() 

391 

392 with conversations_session(traveler_token) as c: 

393 res = c.ListMessageThreads( 

394 conversations_pb2.ListMessageThreadsReq( 

395 categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_MY_PUBLIC_TRIPS] 

396 ) 

397 ) 

398 assert len(res.threads) == 0 

399 assert not res.next_page_token 

400 

401 

402def test_mark_all_threads_seen_rejects_unspecified_category(db): 

403 _user1, token1 = generate_user() 

404 

405 with conversations_session(token1) as c: 

406 with pytest.raises(grpc.RpcError) as e: 

407 c.MarkAllThreadsSeen( 

408 conversations_pb2.MarkAllThreadsSeenReq( 

409 categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_UNSPECIFIED] 

410 ) 

411 ) 

412 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

413 

414 

415def test_mark_all_threads_seen_respects_categories(db, moderator): 

416 user1, token1 = generate_user() 

417 _user2, token2 = generate_user() 

418 

419 # an unread group chat and an unread host request, both from user2 

420 _create_group_chat(token2, [user1.id], moderator, text="hello there") 

421 _create_host_request(token2, user1.id, moderator) 

422 

423 def unseen() -> tuple[int, int]: 

424 with real_api_session(token1) as api: 

425 res = api.Ping(api_pb2.PingReq()) 

426 return res.unseen_message_count, res.unseen_received_host_request_count 

427 

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

429 

430 with conversations_session(token1) as c: 

431 c.MarkAllThreadsSeen( 

432 conversations_pb2.MarkAllThreadsSeenReq(categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_CHATS]) 

433 ) 

434 # the chat is now read; the host request is untouched 

435 assert unseen() == (0, 1) 

436 

437 with conversations_session(token1) as c: 

438 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

439 assert unseen() == (0, 0) 

440 

441 

442def test_mark_all_threads_seen_respects_unread_and_archived_filters(db, moderator): 

443 user1, token1 = generate_user() 

444 _user2, token2 = generate_user() 

445 _user3, token3 = generate_user() 

446 

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

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

449 

450 with conversations_session(token1) as c: 

451 c.SetGroupChatArchiveStatus( 

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

453 ) 

454 

455 def unseen_chat_messages() -> int: 

456 with real_api_session(token1) as api: 

457 return int(api.Ping(api_pb2.PingReq()).unseen_message_count) 

458 

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

460 assert unseen_chat_messages() == 4 

461 

462 with conversations_session(token1) as c: 

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

464 assert unseen_chat_messages() == 2 

465 

466 with conversations_session(token1) as c: 

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

468 assert unseen_chat_messages() == 0 

469 

470 

471def test_departed_group_chat_unseen_count_agrees_with_ping_and_clears(db, moderator): 

472 """ 

473 Regression test: the per-thread unseen count used to include messages sent after the viewer left 

474 the chat, which the Ping badge never counted, and MarkAllThreadsSeen skipped departed chats 

475 entirely — so the list showed unread messages the viewer couldn't read and couldn't clear. 

476 

477 The viewer is removed rather than leaving, because leaving posts a message of your own, which 

478 marks everything up to it seen. 

479 """ 

480 user1, token1 = generate_user() 

481 _user2, token2 = generate_user() 

482 user3, _token3 = generate_user() 

483 

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

485 

486 with conversations_session(token2) as c: 

487 c.RemoveGroupChatUser(conversations_pb2.RemoveGroupChatUserReq(group_chat_id=chat_id, user_id=user1.id)) 

488 for _ in range(3): 

489 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=chat_id, text="after you left")) 

490 

491 def unseen_counts() -> tuple[int, int]: 

492 with real_api_session(token1) as api: 

493 ping_count = api.Ping(api_pb2.PingReq()).unseen_message_count 

494 with conversations_session(token1) as c: 

495 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq()) 

496 thread = next(t for t in res.threads if t.group_chat.group_chat_id == chat_id) 

497 return thread.group_chat.unseen_message_count, ping_count 

498 

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

500 assert unseen_counts() == (3, 3) 

501 

502 with conversations_session(token1) as c: 

503 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

504 assert unseen_counts() == (0, 0) 

505 

506 

507def test_rejoined_group_chat_reads_the_current_subscription(db, moderator): 

508 """ 

509 Regression test: rejoining a chat leaves the earlier subscription behind, with its own last-seen 

510 state. The unread filter and the Ping badge used to match on any of the viewer's subscriptions, 

511 so the stale one held the chat unread and kept the badge up, while the count the list showed and 

512 the row MarkAllThreadsSeen advanced came from the current subscription and could never clear it. 

513 """ 

514 user1, token1 = generate_user() 

515 _user2, token2 = generate_user() 

516 user3, _token3 = generate_user() 

517 

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

519 

520 # removed rather than leaving, so user1's first subscription is left behind with unread messages 

521 with conversations_session(token2) as c: 

522 c.RemoveGroupChatUser(conversations_pb2.RemoveGroupChatUserReq(group_chat_id=chat_id, user_id=user1.id)) 

523 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=chat_id, text="while you were away")) 

524 c.InviteToGroupChat(conversations_pb2.InviteToGroupChatReq(group_chat_id=chat_id, user_id=user1.id)) 

525 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=chat_id, text="welcome back")) 

526 

527 def unread_chat_ids() -> list[int]: 

528 with conversations_session(token1) as c: 

529 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq(only_unread=True)) 

530 return [t.group_chat.group_chat_id for t in res.threads] 

531 

532 def unseen_counts() -> tuple[int, int]: 

533 with real_api_session(token1) as api: 

534 ping_count = api.Ping(api_pb2.PingReq()).unseen_message_count 

535 with conversations_session(token1) as c: 

536 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq()) 

537 threads = [t for t in res.threads if t.group_chat.group_chat_id == chat_id] 

538 # the two subscriptions must not surface the chat twice 

539 assert len(threads) == 1 

540 return threads[0].group_chat.unseen_message_count, ping_count 

541 

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

543 assert unseen_counts() == (2, 2) 

544 assert unread_chat_ids() == [chat_id] 

545 

546 with conversations_session(token1) as c: 

547 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

548 assert unseen_counts() == (0, 0) 

549 assert unread_chat_ids() == [] 

550 

551 

552def test_mark_all_threads_seen_clears_missed_messages_notification(db, moderator, timewarp: Timewarp): 

553 """ 

554 Regression test: chat__missed_messages is a summary keyed with "" rather than a chat id, so it 

555 needs its own (topic actions, keys) group instead of being pooled with the per-chat keys. 

556 """ 

557 user1, token1 = generate_user() 

558 user2, token2 = generate_user() 

559 

560 # this notification is email-only by default, and the in-app feed only shows push-enabled ones 

561 with notifications_session(token2) as n: 

562 n.SetNotificationSettings( 

563 notifications_pb2.SetNotificationSettingsReq( 

564 preferences=[ 

565 notifications_pb2.SingleNotificationPreference( 

566 topic=NotificationTopicAction.chat__missed_messages.topic, 

567 action=NotificationTopicAction.chat__missed_messages.action, 

568 delivery_method="push", 

569 enabled=True, 

570 ) 

571 ] 

572 ) 

573 ) 

574 

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

576 

577 # the job only picks up messages that have been unseen for five minutes 

578 timewarp.advance(MISSED_MESSAGES_DELAY) 

579 send_message_notifications(empty_pb2.Empty()) 

580 process_jobs() 

581 

582 def unseen_missed_messages(): 

583 with notifications_session(token2) as n: 

584 res = n.ListNotifications(notifications_pb2.ListNotificationsReq(only_unread=True)) 

585 return [ 

586 notification 

587 for notification in res.notifications 

588 if notification.topic == NotificationTopicAction.chat__missed_messages.topic 

589 and notification.action == NotificationTopicAction.chat__missed_messages.action 

590 ] 

591 

592 assert len(unseen_missed_messages()) == 1 

593 

594 with conversations_session(token2) as c: 

595 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

596 

597 assert unseen_missed_messages() == []