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

327 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-10 12:25 +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 

29@pytest.fixture(autouse=True) 

30def _(testconfig): 

31 pass 

32 

33 

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

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

36 with session_scope() as session: 

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

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

39 return node_id, trip_id 

40 

41 

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

43 with conversations_session(token) as c: 

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

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

46 moderator.approve_group_chat(res.group_chat_id) 

47 return int(res.group_chat_id) 

48 

49 

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

51 with requests_session(surfer_token) as api: 

52 res = api.CreateHostRequest( 

53 requests_pb2.CreateHostRequestReq( 

54 host_user_id=host_id, 

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

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

57 text=valid_request_text(), 

58 public_trip_id=public_trip_id, 

59 ) 

60 ) 

61 moderator.approve_host_request(res.host_request_id) 

62 return int(res.host_request_id) 

63 

64 

65def test_has_unseen_host_request_messages_is_false_for_a_non_party(db, moderator): 

66 user1, _token1 = generate_user() 

67 _user2, token2 = generate_user() 

68 outsider, _token3 = generate_user() 

69 

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

71 

72 with session_scope() as session: 

73 unseen_for = { 

74 user_id: session.execute( 

75 select(HostRequest.conversation_id) 

76 .where(HostRequest.conversation_id == conversation_id) 

77 .where(has_unseen_host_request_messages(user_id)) 

78 ).scalar_one_or_none() 

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

80 } 

81 

82 assert unseen_for[user1.id] == conversation_id 

83 assert unseen_for[outsider.id] is None 

84 

85 

86def test_list_message_threads_latest_status_change_message(db, moderator): 

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

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

89 user1, token1 = generate_user() 

90 user2, token2 = generate_user() 

91 

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

93 

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

95 with requests_session(token1) as api: 

96 api.RespondHostRequest( 

97 requests_pb2.RespondHostRequestReq( 

98 host_request_id=request_id, 

99 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

100 text="", 

101 ) 

102 ) 

103 

104 with conversations_session(token1) as c: 

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

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

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

108 assert ( 

109 thread.host_request.latest_message.host_request_status_changed.status 

110 == messages_pb2.HOST_REQUEST_STATUS_ACCEPTED 

111 ) 

112 

113 

114def test_list_message_threads_need_host_request_feedback(db, moderator): 

115 user1, token1 = generate_user() 

116 user2, token2 = generate_user() 

117 

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

119 

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

121 with conversations_session(token) as c: 

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

123 thread: requests_pb2.HostRequest = next( 

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

125 ) 

126 return thread 

127 

128 assert not host_request_thread(token1).need_host_request_feedback 

129 

130 with requests_session(token1) as api: 

131 api.RespondHostRequest( 

132 requests_pb2.RespondHostRequestReq( 

133 host_request_id=request_id, 

134 status=messages_pb2.HOST_REQUEST_STATUS_REJECTED, 

135 ) 

136 ) 

137 

138 assert host_request_thread(token1).need_host_request_feedback 

139 # only the host is asked for feedback 

140 assert not host_request_thread(token2).need_host_request_feedback 

141 

142 with requests_session(token1) as api: 

143 api.SendHostRequestFeedback( 

144 requests_pb2.SendHostRequestFeedbackReq( 

145 host_request_id=request_id, 

146 host_request_quality=requests_pb2.HOST_REQUEST_QUALITY_LOW, 

147 ) 

148 ) 

149 

150 assert not host_request_thread(token1).need_host_request_feedback 

151 

152 

153def test_list_message_threads_can_message(db, moderator): 

154 user1, token1 = generate_user() 

155 user2, token2 = generate_user() 

156 user3, token3 = generate_user() 

157 

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

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

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

161 

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

163 with conversations_session(token) as c: 

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

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

166 

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

168 

169 make_user_block(user2, user1) 

170 with conversations_session(token3) as c: 

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

172 

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

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

175 

176 

177def test_list_message_threads_interleaves_chats_and_requests(db, moderator): 

178 user1, token1 = generate_user() 

179 user2, token2 = generate_user() 

180 

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

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

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

184 

185 with conversations_session(token1) as c: 

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

187 

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

189 assert "group_chat" in kinds 

190 assert "host_request" in kinds 

191 ids = { 

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

193 for t in res.threads 

194 } 

195 assert ids == {chat_id, request_id} 

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

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

198 

199 

200def test_list_message_threads_single_cursor_pagination_across_kinds(db, moderator): 

201 user1, token1 = generate_user() 

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

203 

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

205 expected_chat_ids = set() 

206 expected_request_ids = set() 

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

208 for other, other_token in others: 

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

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

211 

212 collected_chat_ids: list[int] = [] 

213 collected_request_ids: list[int] = [] 

214 latest_ids: list[int] = [] 

215 page_token = "" 

216 while True: 

217 with conversations_session(token1) as c: 

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

219 for t in res.threads: 

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

221 collected_chat_ids.append(t.group_chat.group_chat_id) 

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

223 else: 

224 collected_request_ids.append(t.host_request.host_request_id) 

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

226 if not res.next_page_token: 

227 break 

228 page_token = res.next_page_token 

229 

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

231 assert sorted(collected_chat_ids) == sorted(expected_chat_ids) 

232 assert sorted(collected_request_ids) == sorted(expected_request_ids) 

233 assert len(collected_chat_ids) == len(set(collected_chat_ids)) 

234 assert len(collected_request_ids) == len(set(collected_request_ids)) 

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

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

237 

238 

239def test_list_message_threads_chats_filter_excludes_host_requests(db, moderator): 

240 user1, token1 = generate_user() 

241 user2, token2 = generate_user() 

242 

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

244 _create_host_request(token2, user1.id, moderator) 

245 

246 with conversations_session(token1) as c: 

247 res = c.ListMessageThreads( 

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

249 ) 

250 

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

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

253 

254 

255def test_list_message_threads_roster_is_frozen_for_a_departed_viewer(db, moderator): 

256 """ 

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

258 afterwards is invisible to them. 

259 """ 

260 admin, admin_token = generate_user() 

261 viewer, viewer_token = generate_user() 

262 member, _member_token = generate_user() 

263 latecomer, _latecomer_token = generate_user() 

264 

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

266 

267 with conversations_session(admin_token) as c: 

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

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

270 

271 with conversations_session(viewer_token) as c: 

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

273 departed_view = res.threads[0].group_chat 

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

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

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

277 assert departed_view.unseen_message_count == 3 

278 

279 with conversations_session(admin_token) as c: 

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

281 admin_view = res.threads[0].group_chat 

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

283 

284 

285def test_list_message_threads_rejects_unspecified_category(db): 

286 _user1, token1 = generate_user() 

287 

288 with conversations_session(token1) as c: 

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

290 c.ListMessageThreads( 

291 conversations_pb2.ListMessageThreadsReq( 

292 categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_UNSPECIFIED] 

293 ) 

294 ) 

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

296 

297 

298def test_list_message_threads_unread_filter(db, moderator): 

299 user1, token1 = generate_user() 

300 user2, token2 = generate_user() 

301 

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

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

304 

305 with conversations_session(token1) as c: 

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

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

308 

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

310 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

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

312 assert len(res.threads) == 0 

313 

314 

315def test_list_message_threads_archived_is_orthogonal(db, moderator): 

316 user1, token1 = generate_user() 

317 user2, token2 = generate_user() 

318 

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

320 

321 with conversations_session(token1) as c: 

322 # archive the chat 

323 c.SetGroupChatArchiveStatus( 

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

325 ) 

326 

327 # default (non-archived) excludes it 

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

329 assert len(res.threads) == 0 

330 

331 # only_archived=True includes it 

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

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

334 

335 

336def test_list_message_threads_public_trip_offer_role_based(db, moderator): 

337 traveler, traveler_token = generate_user() 

338 host, host_token = generate_user() 

339 _, trip_id = _make_trip(traveler) 

340 

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

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

343 

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

345 with conversations_session(host_token) as c: 

346 res = c.ListMessageThreads( 

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

348 ) 

349 assert len(res.threads) == 1 

350 hr = res.threads[0].host_request 

351 assert hr.host_request_id == request_id 

352 assert hr.HasField("public_trip_id") 

353 assert hr.public_trip_id == trip_id 

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

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

356 assert hr.surfer_user_id == traveler.id 

357 assert hr.host_user_id == host.id 

358 

359 # not under SURFING for the host 

360 res = c.ListMessageThreads( 

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

362 ) 

363 assert len(res.threads) == 0 

364 

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

366 with requests_session(host_token) as api: 

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

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

369 

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

371 with conversations_session(traveler_token) as c: 

372 res = c.ListMessageThreads( 

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

374 ) 

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

376 # same payload regardless of viewer 

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

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

379 

380 res = c.ListMessageThreads( 

381 conversations_pb2.ListMessageThreadsReq( 

382 categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_MY_PUBLIC_TRIPS] 

383 ) 

384 ) 

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

386 

387 # also present in ALL 

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

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

390 

391 

392def test_list_message_threads_public_trips_filter_gated_by_flag(db, moderator, feature_flags): 

393 feature_flags.set("public_trips_enabled", False) 

394 

395 traveler, traveler_token = generate_user() 

396 

397 with conversations_session(traveler_token) as c: 

398 res = c.ListMessageThreads( 

399 conversations_pb2.ListMessageThreadsReq( 

400 categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_MY_PUBLIC_TRIPS] 

401 ) 

402 ) 

403 assert len(res.threads) == 0 

404 assert not res.next_page_token 

405 

406 

407def test_mark_all_threads_seen_rejects_unspecified_category(db): 

408 _user1, token1 = generate_user() 

409 

410 with conversations_session(token1) as c: 

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

412 c.MarkAllThreadsSeen( 

413 conversations_pb2.MarkAllThreadsSeenReq( 

414 categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_UNSPECIFIED] 

415 ) 

416 ) 

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

418 

419 

420def test_mark_all_threads_seen_respects_categories(db, moderator): 

421 user1, token1 = generate_user() 

422 _user2, token2 = generate_user() 

423 

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

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

426 _create_host_request(token2, user1.id, moderator) 

427 

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

429 with real_api_session(token1) as api: 

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

431 return res.unseen_message_count, res.unseen_received_host_request_count 

432 

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

434 

435 with conversations_session(token1) as c: 

436 c.MarkAllThreadsSeen( 

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

438 ) 

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

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

441 

442 with conversations_session(token1) as c: 

443 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

444 assert unseen() == (0, 0) 

445 

446 

447def test_mark_all_threads_seen_respects_unread_and_archived_filters(db, moderator): 

448 user1, token1 = generate_user() 

449 _user2, token2 = generate_user() 

450 _user3, token3 = generate_user() 

451 

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

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

454 

455 with conversations_session(token1) as c: 

456 c.SetGroupChatArchiveStatus( 

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

458 ) 

459 

460 def unseen_chat_messages() -> int: 

461 with real_api_session(token1) as api: 

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

463 

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

465 assert unseen_chat_messages() == 4 

466 

467 with conversations_session(token1) as c: 

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

469 assert unseen_chat_messages() == 2 

470 

471 with conversations_session(token1) as c: 

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

473 assert unseen_chat_messages() == 0 

474 

475 

476def test_departed_group_chat_unseen_count_agrees_with_ping_and_clears(db, moderator): 

477 """ 

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

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

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

481 

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

483 marks everything up to it seen. 

484 """ 

485 user1, token1 = generate_user() 

486 _user2, token2 = generate_user() 

487 user3, _token3 = generate_user() 

488 

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

490 

491 with conversations_session(token2) as c: 

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

493 for _ in range(3): 

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

495 

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

497 with real_api_session(token1) as api: 

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

499 with conversations_session(token1) as c: 

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

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

502 return thread.group_chat.unseen_message_count, ping_count 

503 

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

505 assert unseen_counts() == (3, 3) 

506 

507 with conversations_session(token1) as c: 

508 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

509 assert unseen_counts() == (0, 0) 

510 

511 

512def test_rejoined_group_chat_reads_the_current_subscription(db, moderator): 

513 """ 

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

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

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

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

518 """ 

519 user1, token1 = generate_user() 

520 _user2, token2 = generate_user() 

521 user3, _token3 = generate_user() 

522 

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

524 

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

526 with conversations_session(token2) as c: 

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

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

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

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

531 

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

533 with conversations_session(token1) as c: 

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

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

536 

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

538 with real_api_session(token1) as api: 

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

540 with conversations_session(token1) as c: 

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

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

543 # the two subscriptions must not surface the chat twice 

544 assert len(threads) == 1 

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

546 

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

548 assert unseen_counts() == (2, 2) 

549 assert unread_chat_ids() == [chat_id] 

550 

551 with conversations_session(token1) as c: 

552 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

553 assert unseen_counts() == (0, 0) 

554 assert unread_chat_ids() == [] 

555 

556 

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

558 """ 

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

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

561 """ 

562 user1, token1 = generate_user() 

563 user2, token2 = generate_user() 

564 

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

566 with notifications_session(token2) as n: 

567 n.SetNotificationSettings( 

568 notifications_pb2.SetNotificationSettingsReq( 

569 preferences=[ 

570 notifications_pb2.SingleNotificationPreference( 

571 topic=NotificationTopicAction.chat__missed_messages.topic, 

572 action=NotificationTopicAction.chat__missed_messages.action, 

573 delivery_method="push", 

574 enabled=True, 

575 ) 

576 ] 

577 ) 

578 ) 

579 

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

581 

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

583 timewarp.advance(MISSED_MESSAGES_DELAY) 

584 send_message_notifications(empty_pb2.Empty()) 

585 process_jobs() 

586 

587 def unseen_missed_messages(): 

588 with notifications_session(token2) as n: 

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

590 return [ 

591 notification 

592 for notification in res.notifications 

593 if notification.topic == NotificationTopicAction.chat__missed_messages.topic 

594 and notification.action == NotificationTopicAction.chat__missed_messages.action 

595 ] 

596 

597 assert len(unseen_missed_messages()) == 1 

598 

599 with conversations_session(token2) as c: 

600 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq()) 

601 

602 assert unseen_missed_messages() == []