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

336 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-07 10:09 +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_no_feedback_for_declined_public_trip_offer(db, moderator): 

149 traveller, traveller_token = generate_user() 

150 _host, host_token = generate_user() 

151 _, trip_id = _make_trip(traveller) 

152 

153 request_id = _create_host_request(host_token, traveller.id, moderator, public_trip_id=trip_id) 

154 

155 # the traveller is the recipient of an offer, so they're the one who declines 

156 with requests_session(traveller_token) as api: 

157 api.RespondHostRequest( 

158 requests_pb2.RespondHostRequestReq( 

159 host_request_id=request_id, 

160 status=messages_pb2.HOST_REQUEST_STATUS_REJECTED, 

161 ) 

162 ) 

163 

164 for token in (traveller_token, host_token): 

165 with conversations_session(token) as c: 

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

167 thread = next(t.host_request for t in res.threads if t.host_request.host_request_id == request_id) 

168 assert not thread.need_host_request_feedback 

169 

170 

171def test_list_message_threads_can_message(db, moderator): 

172 user1, token1 = generate_user() 

173 user2, token2 = generate_user() 

174 user3, token3 = generate_user() 

175 

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

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

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

179 

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

181 with conversations_session(token) as c: 

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

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

184 

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

186 

187 make_user_block(user2, user1) 

188 with conversations_session(token3) as c: 

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

190 

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

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

193 

194 

195def test_list_message_threads_interleaves_chats_and_requests(db, moderator): 

196 user1, token1 = generate_user() 

197 user2, token2 = generate_user() 

198 

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

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

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

202 

203 with conversations_session(token1) as c: 

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

205 

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

207 assert "group_chat" in kinds 

208 assert "host_request" in kinds 

209 ids = { 

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

211 for t in res.threads 

212 } 

213 assert ids == {chat_id, request_id} 

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

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

216 

217 

218def test_list_message_threads_single_cursor_pagination_across_kinds(db, moderator): 

219 user1, token1 = generate_user() 

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

221 

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

223 expected_chat_ids = set() 

224 expected_request_ids = set() 

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

226 for other, other_token in others: 

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

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

229 

230 collected_chat_ids: list[int] = [] 

231 collected_request_ids: list[int] = [] 

232 latest_ids: list[int] = [] 

233 page_token = "" 

234 while True: 

235 with conversations_session(token1) as c: 

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

237 for t in res.threads: 

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

239 collected_chat_ids.append(t.group_chat.group_chat_id) 

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

241 else: 

242 collected_request_ids.append(t.host_request.host_request_id) 

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

244 if not res.next_page_token: 

245 break 

246 page_token = res.next_page_token 

247 

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

249 assert sorted(collected_chat_ids) == sorted(expected_chat_ids) 

250 assert sorted(collected_request_ids) == sorted(expected_request_ids) 

251 assert len(collected_chat_ids) == len(set(collected_chat_ids)) 

252 assert len(collected_request_ids) == len(set(collected_request_ids)) 

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

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

255 

256 

257def test_list_message_threads_chats_filter_excludes_host_requests(db, moderator): 

258 user1, token1 = generate_user() 

259 user2, token2 = generate_user() 

260 

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

262 _create_host_request(token2, user1.id, moderator) 

263 

264 with conversations_session(token1) as c: 

265 res = c.ListMessageThreads( 

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

267 ) 

268 

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

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

271 

272 

273def test_list_message_threads_roster_is_frozen_for_a_departed_viewer(db, moderator): 

274 """ 

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

276 afterwards is invisible to them. 

277 """ 

278 admin, admin_token = generate_user() 

279 viewer, viewer_token = generate_user() 

280 member, _member_token = generate_user() 

281 latecomer, _latecomer_token = generate_user() 

282 

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

284 

285 with conversations_session(admin_token) as c: 

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

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

288 

289 with conversations_session(viewer_token) as c: 

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

291 departed_view = res.threads[0].group_chat 

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

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

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

295 assert departed_view.unseen_message_count == 3 

296 

297 with conversations_session(admin_token) as c: 

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

299 admin_view = res.threads[0].group_chat 

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

301 

302 

303def test_list_message_threads_rejects_unspecified_category(db): 

304 _user1, token1 = generate_user() 

305 

306 with conversations_session(token1) as c: 

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

308 c.ListMessageThreads( 

309 conversations_pb2.ListMessageThreadsReq( 

310 categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_UNSPECIFIED] 

311 ) 

312 ) 

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

314 

315 

316def test_list_message_threads_unread_filter(db, moderator): 

317 user1, token1 = generate_user() 

318 user2, token2 = generate_user() 

319 

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

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

322 

323 with conversations_session(token1) as c: 

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

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

326 

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

328 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

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

330 assert len(res.threads) == 0 

331 

332 

333def test_list_message_threads_archived_is_orthogonal(db, moderator): 

334 user1, token1 = generate_user() 

335 user2, token2 = generate_user() 

336 

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

338 

339 with conversations_session(token1) as c: 

340 # archive the chat 

341 c.SetGroupChatArchiveStatus( 

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

343 ) 

344 

345 # default (non-archived) excludes it 

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

347 assert len(res.threads) == 0 

348 

349 # only_archived=True includes it 

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

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

352 

353 

354def test_list_message_threads_public_trip_offer_role_based(db, moderator): 

355 traveler, traveler_token = generate_user() 

356 host, host_token = generate_user() 

357 _, trip_id = _make_trip(traveler) 

358 

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

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

361 

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

363 with conversations_session(host_token) as c: 

364 res = c.ListMessageThreads( 

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

366 ) 

367 assert len(res.threads) == 1 

368 hr = res.threads[0].host_request 

369 assert hr.host_request_id == request_id 

370 assert hr.HasField("public_trip_id") 

371 assert hr.public_trip_id == trip_id 

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

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

374 assert hr.surfer_user_id == traveler.id 

375 assert hr.host_user_id == host.id 

376 

377 # not under SURFING for the host 

378 res = c.ListMessageThreads( 

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

380 ) 

381 assert len(res.threads) == 0 

382 

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

384 with requests_session(host_token) as api: 

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

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

387 

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

389 with conversations_session(traveler_token) as c: 

390 res = c.ListMessageThreads( 

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

392 ) 

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

394 # same payload regardless of viewer 

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

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

397 

398 res = c.ListMessageThreads( 

399 conversations_pb2.ListMessageThreadsReq( 

400 categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_MY_PUBLIC_TRIPS] 

401 ) 

402 ) 

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

404 

405 # also present in ALL 

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

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

408 

409 

410def test_list_message_threads_public_trips_filter_gated_by_flag(db, moderator, feature_flags): 

411 feature_flags.set("public_trips_enabled", False) 

412 

413 traveler, traveler_token = generate_user() 

414 

415 with conversations_session(traveler_token) as c: 

416 res = c.ListMessageThreads( 

417 conversations_pb2.ListMessageThreadsReq( 

418 categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_MY_PUBLIC_TRIPS] 

419 ) 

420 ) 

421 assert len(res.threads) == 0 

422 assert not res.next_page_token 

423 

424 

425def test_mark_all_threads_seen_rejects_unspecified_category(db): 

426 _user1, token1 = generate_user() 

427 

428 with conversations_session(token1) as c: 

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

430 c.MarkAllThreadsSeen( 

431 conversations_pb2.MarkAllThreadsSeenReq( 

432 categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_UNSPECIFIED] 

433 ) 

434 ) 

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

436 

437 

438def test_mark_all_threads_seen_respects_categories(db, moderator): 

439 user1, token1 = generate_user() 

440 _user2, token2 = generate_user() 

441 

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

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

444 _create_host_request(token2, user1.id, moderator) 

445 

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

447 with real_api_session(token1) as api: 

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

449 return res.unseen_message_count, res.unseen_received_host_request_count 

450 

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

452 

453 with conversations_session(token1) as c: 

454 c.MarkAllThreadsSeen( 

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

456 ) 

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

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

459 

460 with conversations_session(token1) as c: 

461 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

462 assert unseen() == (0, 0) 

463 

464 

465def test_mark_all_threads_seen_respects_unread_and_archived_filters(db, moderator): 

466 user1, token1 = generate_user() 

467 _user2, token2 = generate_user() 

468 _user3, token3 = generate_user() 

469 

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

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

472 

473 with conversations_session(token1) as c: 

474 c.SetGroupChatArchiveStatus( 

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

476 ) 

477 

478 def unseen_chat_messages() -> int: 

479 with real_api_session(token1) as api: 

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

481 

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

483 assert unseen_chat_messages() == 4 

484 

485 with conversations_session(token1) as c: 

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

487 assert unseen_chat_messages() == 2 

488 

489 with conversations_session(token1) as c: 

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

491 assert unseen_chat_messages() == 0 

492 

493 

494def test_departed_group_chat_unseen_count_agrees_with_ping_and_clears(db, moderator): 

495 """ 

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

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

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

499 

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

501 marks everything up to it seen. 

502 """ 

503 user1, token1 = generate_user() 

504 _user2, token2 = generate_user() 

505 user3, _token3 = generate_user() 

506 

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

508 

509 with conversations_session(token2) as c: 

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

511 for _ in range(3): 

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

513 

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

515 with real_api_session(token1) as api: 

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

517 with conversations_session(token1) as c: 

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

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

520 return thread.group_chat.unseen_message_count, ping_count 

521 

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

523 assert unseen_counts() == (3, 3) 

524 

525 with conversations_session(token1) as c: 

526 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

527 assert unseen_counts() == (0, 0) 

528 

529 

530def test_rejoined_group_chat_reads_the_current_subscription(db, moderator): 

531 """ 

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

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

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

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

536 """ 

537 user1, token1 = generate_user() 

538 _user2, token2 = generate_user() 

539 user3, _token3 = generate_user() 

540 

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

542 

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

544 with conversations_session(token2) as c: 

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

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

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

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

549 

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

551 with conversations_session(token1) as c: 

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

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

554 

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

556 with real_api_session(token1) as api: 

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

558 with conversations_session(token1) as c: 

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

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

561 # the two subscriptions must not surface the chat twice 

562 assert len(threads) == 1 

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

564 

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

566 assert unseen_counts() == (2, 2) 

567 assert unread_chat_ids() == [chat_id] 

568 

569 with conversations_session(token1) as c: 

570 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

571 assert unseen_counts() == (0, 0) 

572 assert unread_chat_ids() == [] 

573 

574 

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

576 """ 

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

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

579 """ 

580 user1, token1 = generate_user() 

581 user2, token2 = generate_user() 

582 

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

584 with notifications_session(token2) as n: 

585 n.SetNotificationSettings( 

586 notifications_pb2.SetNotificationSettingsReq( 

587 preferences=[ 

588 notifications_pb2.SingleNotificationPreference( 

589 topic=NotificationTopicAction.chat__missed_messages.topic, 

590 action=NotificationTopicAction.chat__missed_messages.action, 

591 delivery_method="push", 

592 enabled=True, 

593 ) 

594 ] 

595 ) 

596 ) 

597 

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

599 

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

601 timewarp.advance(MISSED_MESSAGES_DELAY) 

602 send_message_notifications(empty_pb2.Empty()) 

603 process_jobs() 

604 

605 def unseen_missed_messages(): 

606 with notifications_session(token2) as n: 

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

608 return [ 

609 notification 

610 for notification in res.notifications 

611 if notification.topic == NotificationTopicAction.chat__missed_messages.topic 

612 and notification.action == NotificationTopicAction.chat__missed_messages.action 

613 ] 

614 

615 assert len(unseen_missed_messages()) == 1 

616 

617 with conversations_session(token2) as c: 

618 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

619 

620 assert unseen_missed_messages() == []