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
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 15:29 +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_can_message(db, moderator):
149 user1, token1 = generate_user()
150 user2, token2 = generate_user()
151 user3, token3 = generate_user()
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)
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}
162 assert can_message_by_chat(token1) == {blocked_dm_id: True, departed_dm_id: True, chat_id: True}
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))
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}
172def test_list_message_threads_interleaves_chats_and_requests(db, moderator):
173 user1, token1 = generate_user()
174 user2, token2 = generate_user()
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)
180 with conversations_session(token1) as c:
181 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq())
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"
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)]
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))
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
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)
234def test_list_message_threads_chats_filter_excludes_host_requests(db, moderator):
235 user1, token1 = generate_user()
236 user2, token2 = generate_user()
238 chat_id = _create_group_chat(token1, [user2.id], moderator)
239 _create_host_request(token2, user1.id, moderator)
241 with conversations_session(token1) as c:
242 res = c.ListMessageThreads(
243 conversations_pb2.ListMessageThreadsReq(categories=[conversations_pb2.MESSAGE_THREAD_CATEGORY_CHATS])
244 )
246 assert [t.WhichOneof("thread") for t in res.threads] == ["group_chat"]
247 assert res.threads[0].group_chat.group_chat_id == chat_id
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()
260 chat_id = _create_group_chat(admin_token, [viewer.id, member.id], moderator)
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))
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
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}
280def test_list_message_threads_rejects_unspecified_category(db):
281 _user1, token1 = generate_user()
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
293def test_list_message_threads_unread_filter(db, moderator):
294 user1, token1 = generate_user()
295 user2, token2 = generate_user()
297 # user2 sends a request to user1 -> user1 has unseen messages
298 request_id = _create_host_request(token2, user1.id, moderator)
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]
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
310def test_list_message_threads_archived_is_orthogonal(db, moderator):
311 user1, token1 = generate_user()
312 user2, token2 = generate_user()
314 chat_id = _create_group_chat(token1, [user2.id], moderator)
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 )
322 # default (non-archived) excludes it
323 res = c.ListMessageThreads(conversations_pb2.ListMessageThreadsReq(only_archived=False))
324 assert len(res.threads) == 0
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]
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)
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)
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
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
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)
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
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]
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}
387def test_list_message_threads_public_trips_filter_gated_by_flag(db, moderator, feature_flags):
388 feature_flags.set("public_trips_enabled", False)
390 traveler, traveler_token = generate_user()
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
402def test_mark_all_threads_seen_rejects_unspecified_category(db):
403 _user1, token1 = generate_user()
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
415def test_mark_all_threads_seen_respects_categories(db, moderator):
416 user1, token1 = generate_user()
417 _user2, token2 = generate_user()
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)
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
428 assert unseen() == (2, 1)
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)
437 with conversations_session(token1) as c:
438 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq())
439 assert unseen() == (0, 0)
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()
447 archived_chat_id = _create_group_chat(token2, [user1.id], moderator, text="archived")
448 _create_group_chat(token3, [user1.id], moderator, text="not archived")
450 with conversations_session(token1) as c:
451 c.SetGroupChatArchiveStatus(
452 conversations_pb2.SetGroupChatArchiveStatusReq(group_chat_id=archived_chat_id, is_archived=True)
453 )
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)
459 # two messages in each chat: the creation notice and the text
460 assert unseen_chat_messages() == 4
462 with conversations_session(token1) as c:
463 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq(only_archived=True))
464 assert unseen_chat_messages() == 2
466 with conversations_session(token1) as c:
467 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq(only_unread=True))
468 assert unseen_chat_messages() == 0
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.
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()
484 chat_id = _create_group_chat(token2, [user1.id, user3.id], moderator)
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"))
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
499 # chat created, "hi", and the removal notice; the three messages sent afterwards are out of reach
500 assert unseen_counts() == (3, 3)
502 with conversations_session(token1) as c:
503 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq())
504 assert unseen_counts() == (0, 0)
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()
518 chat_id = _create_group_chat(token2, [user1.id, user3.id], moderator)
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"))
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]
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
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]
546 with conversations_session(token1) as c:
547 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq())
548 assert unseen_counts() == (0, 0)
549 assert unread_chat_ids() == []
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()
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 )
575 _create_group_chat(token1, [user2.id], moderator, text="hello there")
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()
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 ]
592 assert len(unseen_missed_messages()) == 1
594 with conversations_session(token2) as c:
595 c.MarkAllThreadsSeen(conversations_pb2.MarkAllThreadsSeenReq())
597 assert unseen_missed_messages() == []