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
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-07 10:09 +0000
1from datetime import timedelta
3import grpc
4import pytest
5from google.protobuf import empty_pb2
6from sqlalchemy import select
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
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
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)
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)
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()
65 conversation_id = _create_host_request(token2, user1.id, moderator)
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 }
77 assert unseen_for[user1.id] == conversation_id
78 assert unseen_for[outsider.id] is None
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()
87 request_id = _create_host_request(token2, user1.id, moderator)
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 )
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 )
109def test_list_message_threads_need_host_request_feedback(db, moderator):
110 user1, token1 = generate_user()
111 user2, token2 = generate_user()
113 request_id = _create_host_request(token2, user1.id, moderator)
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
123 assert not host_request_thread(token1).need_host_request_feedback
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 )
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
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 )
145 assert not host_request_thread(token1).need_host_request_feedback
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)
153 request_id = _create_host_request(host_token, traveller.id, moderator, public_trip_id=trip_id)
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 )
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
171def test_list_message_threads_can_message(db, moderator):
172 user1, token1 = generate_user()
173 user2, token2 = generate_user()
174 user3, token3 = generate_user()
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)
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}
185 assert can_message_by_chat(token1) == {blocked_dm_id: True, departed_dm_id: True, chat_id: True}
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))
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}
195def test_list_message_threads_interleaves_chats_and_requests(db, moderator):
196 user1, token1 = generate_user()
197 user2, token2 = generate_user()
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)
203 with conversations_session(token1) as c:
204 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq())
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"
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)]
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))
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
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)
257def test_list_message_threads_chats_filter_excludes_host_requests(db, moderator):
258 user1, token1 = generate_user()
259 user2, token2 = generate_user()
261 chat_id = _create_group_chat(token1, [user2.id], moderator)
262 _create_host_request(token2, user1.id, moderator)
264 with conversations_session(token1) as c:
265 res = c.ListMessageThreads(
266 conversations_pb2.ListMessageThreadsReq(categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_CHATS])
267 )
269 assert [t.WhichOneof("thread") for t in res.threads] == ["group_chat"]
270 assert res.threads[0].group_chat.group_chat_id == chat_id
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()
283 chat_id = _create_group_chat(admin_token, [viewer.id, member.id], moderator)
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))
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
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}
303def test_list_message_threads_rejects_unspecified_category(db):
304 _user1, token1 = generate_user()
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
316def test_list_message_threads_unread_filter(db, moderator):
317 user1, token1 = generate_user()
318 user2, token2 = generate_user()
320 # user2 sends a request to user1 -> user1 has unseen messages
321 request_id = _create_host_request(token2, user1.id, moderator)
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]
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
333def test_list_message_threads_archived_is_orthogonal(db, moderator):
334 user1, token1 = generate_user()
335 user2, token2 = generate_user()
337 chat_id = _create_group_chat(token1, [user2.id], moderator)
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 )
345 # default (non-archived) excludes it
346 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq(only_archived=False))
347 assert len(res.threads) == 0
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]
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)
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)
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
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
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)
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
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]
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}
410def test_list_message_threads_public_trips_filter_gated_by_flag(db, moderator, feature_flags):
411 feature_flags.set("public_trips_enabled", False)
413 traveler, traveler_token = generate_user()
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
425def test_mark_all_threads_seen_rejects_unspecified_category(db):
426 _user1, token1 = generate_user()
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
438def test_mark_all_threads_seen_respects_categories(db, moderator):
439 user1, token1 = generate_user()
440 _user2, token2 = generate_user()
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)
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
451 assert unseen() == (2, 1)
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)
460 with conversations_session(token1) as c:
461 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq())
462 assert unseen() == (0, 0)
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()
470 archived_chat_id = _create_group_chat(token2, [user1.id], moderator, text="archived")
471 _create_group_chat(token3, [user1.id], moderator, text="not archived")
473 with conversations_session(token1) as c:
474 c.SetGroupChatArchiveStatus(
475 conversations_pb2.SetGroupChatArchiveStatusReq(group_chat_id=archived_chat_id, is_archived=True)
476 )
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)
482 # two messages in each chat: the creation notice and the text
483 assert unseen_chat_messages() == 4
485 with conversations_session(token1) as c:
486 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq(only_archived=True))
487 assert unseen_chat_messages() == 2
489 with conversations_session(token1) as c:
490 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq(only_unread=True))
491 assert unseen_chat_messages() == 0
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.
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()
507 chat_id = _create_group_chat(token2, [user1.id, user3.id], moderator)
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"))
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
522 # chat created, "hi", and the removal notice; the three messages sent afterwards are out of reach
523 assert unseen_counts() == (3, 3)
525 with conversations_session(token1) as c:
526 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq())
527 assert unseen_counts() == (0, 0)
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()
541 chat_id = _create_group_chat(token2, [user1.id, user3.id], moderator)
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"))
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]
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
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]
569 with conversations_session(token1) as c:
570 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq())
571 assert unseen_counts() == (0, 0)
572 assert unread_chat_ids() == []
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()
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 )
598 _create_group_chat(token1, [user2.id], moderator, text="hello there")
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()
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 ]
615 assert len(unseen_missed_messages()) == 1
617 with conversations_session(token2) as c:
618 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq())
620 assert unseen_missed_messages() == []