Coverage for app/backend/src/tests/test_message_threads.py: 100%
208 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-22 16:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-22 16:01 +0000
1from datetime import timedelta
3import grpc
4import pytest
6from couchers.db import session_scope
7from couchers.models import Cluster, Node, NodeType
8from couchers.models.public_trips import PublicTrip, PublicTripStatus
9from couchers.proto import api_pb2, conversations_pb2, requests_pb2
10from couchers.utils import create_polygon_lat_lng, to_multi, today
11from tests.fixtures.db import generate_user
12from tests.fixtures.sessions import conversations_session, real_api_session, requests_session
15@pytest.fixture(autouse=True)
16def _(testconfig):
17 pass
20HOST_REQUEST_TEXT = "a" * 300
23def _make_trip(user_id: int) -> tuple[int, int]:
24 """Create a community node + an active public trip for the given traveller."""
25 with session_scope() as session:
26 node = Node(
27 geom=to_multi(create_polygon_lat_lng([[60, 24], [60, 26], [62, 26], [62, 24], [60, 24]])),
28 node_type=NodeType.locality,
29 )
30 session.add(node)
31 session.flush()
32 session.add(
33 Cluster(
34 name="Test community",
35 description="Test",
36 parent_node_id=node.id,
37 is_official_cluster=True,
38 small_community_features_enabled=True,
39 )
40 )
41 session.flush()
42 trip = PublicTrip(
43 user_id=user_id,
44 node_id=node.id,
45 from_date=today() + timedelta(days=5),
46 to_date=today() + timedelta(days=10),
47 description="x" * 200,
48 status=PublicTripStatus.searching_for_host,
49 )
50 session.add(trip)
51 session.flush()
52 return node.id, trip.id
55def _create_group_chat(token: str, recipient_ids: list[int], moderator, text: str = "hi") -> int:
56 with conversations_session(token) as c:
57 res = c.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=recipient_ids))
58 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=res.group_chat_id, text=text))
59 moderator.approve_group_chat(res.group_chat_id)
60 return int(res.group_chat_id)
63def _create_host_request(surfer_token: str, host_id: int, moderator, public_trip_id: int | None = None) -> int:
64 with requests_session(surfer_token) as api:
65 res = api.CreateHostRequest(
66 requests_pb2.CreateHostRequestReq(
67 host_user_id=host_id,
68 from_date=(today() + timedelta(days=5)).isoformat(),
69 to_date=(today() + timedelta(days=10)).isoformat(),
70 text=HOST_REQUEST_TEXT,
71 public_trip_id=public_trip_id,
72 )
73 )
74 moderator.approve_host_request(res.host_request_id)
75 return int(res.host_request_id)
78def test_list_message_threads_latest_status_change_message(db, moderator):
79 # Regression: a thread whose latest message is a host-request status change
80 # must serialize with its content set (not an empty control message).
81 user1, token1 = generate_user()
82 user2, token2 = generate_user()
84 request_id = _create_host_request(token2, user1.id, moderator)
86 # user1 (the host) accepts, so the latest message becomes a status change
87 with requests_session(token1) as api:
88 api.RespondHostRequest(
89 requests_pb2.RespondHostRequestReq(
90 host_request_id=request_id,
91 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
92 text="",
93 )
94 )
96 with conversations_session(token1) as c:
97 res = c.ListMessageThreads(
98 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_ALL)
99 )
100 thread = next(t for t in res.threads if t.WhichOneof("thread") == "host_request")
101 assert thread.host_request.latest_message.WhichOneof("content") == "host_request_status_changed"
102 assert (
103 thread.host_request.latest_message.host_request_status_changed.status
104 == conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED
105 )
108def test_list_message_threads_interleaves_chats_and_requests(db, moderator):
109 user1, token1 = generate_user()
110 user2, token2 = generate_user()
112 chat_id = _create_group_chat(token1, [user2.id], moderator)
113 # user2 sends a host request to user1 (user1 is the host)
114 request_id = _create_host_request(token2, user1.id, moderator)
116 with conversations_session(token1) as c:
117 res = c.ListMessageThreads(
118 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_ALL)
119 )
121 kinds = [t.WhichOneof("thread") for t in res.threads]
122 assert "group_chat" in kinds
123 assert "host_request" in kinds
124 ids = {
125 (t.group_chat.group_chat_id if t.WhichOneof("thread") == "group_chat" else t.host_request.host_request_id)
126 for t in res.threads
127 }
128 assert ids == {chat_id, request_id}
129 # The host request was created after the group chat, so it sorts first (latest message).
130 assert res.threads[0].WhichOneof("thread") == "host_request"
133def test_list_message_threads_single_cursor_pagination_across_kinds(db, moderator):
134 user1, token1 = generate_user()
135 others = [generate_user() for _ in range(6)]
137 expected_ids = set()
138 # interleave creating group chats and host requests so both kinds straddle page boundaries
139 for i, (other, other_token) in enumerate(others):
140 expected_ids.add(_create_group_chat(token1, [other.id], moderator))
141 expected_ids.add(_create_host_request(other_token, user1.id, moderator))
143 collected: list[int] = []
144 latest_ids: list[int] = []
145 page_token = ""
146 while True:
147 with conversations_session(token1) as c:
148 res = c.ListMessageThreads(
149 conversations_pb2.ListMessageThreadsReq(
150 filter=conversations_pb2.MESSAGE_THREAD_FILTER_ALL, page_size=3, page_token=page_token
151 )
152 )
153 for t in res.threads:
154 if t.WhichOneof("thread") == "group_chat":
155 collected.append(t.group_chat.group_chat_id)
156 latest_ids.append(t.group_chat.latest_message.message_id)
157 else:
158 collected.append(t.host_request.host_request_id)
159 latest_ids.append(t.host_request.latest_message.message_id)
160 if not res.next_page_token:
161 break
162 page_token = res.next_page_token
164 # every thread appears exactly once, none missing or duplicated
165 assert sorted(collected) == sorted(expected_ids)
166 assert len(collected) == len(set(collected))
167 # globally ordered by latest message id, descending, with no straddling across pages
168 assert latest_ids == sorted(latest_ids, reverse=True)
171def test_list_message_threads_chats_filter_excludes_host_requests(db, moderator):
172 user1, token1 = generate_user()
173 user2, token2 = generate_user()
175 chat_id = _create_group_chat(token1, [user2.id], moderator)
176 _create_host_request(token2, user1.id, moderator)
178 with conversations_session(token1) as c:
179 res = c.ListMessageThreads(
180 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_CHATS)
181 )
183 assert [t.WhichOneof("thread") for t in res.threads] == ["group_chat"]
184 assert res.threads[0].group_chat.group_chat_id == chat_id
187def test_message_threads_reject_unspecified_filter(db):
188 user1, token1 = generate_user()
190 with conversations_session(token1) as c:
191 with pytest.raises(grpc.RpcError) as e:
192 c.ListMessageThreads(
193 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_UNSPECIFIED)
194 )
195 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
197 with pytest.raises(grpc.RpcError) as e:
198 c.MarkAllThreadsSeen(
199 conversations_pb2.MarkAllThreadsSeenReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_UNSPECIFIED)
200 )
201 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
204def test_list_message_threads_unread_filter(db, moderator):
205 user1, token1 = generate_user()
206 user2, token2 = generate_user()
208 # user2 sends a request to user1 -> user1 has unseen messages
209 request_id = _create_host_request(token2, user1.id, moderator)
211 with conversations_session(token1) as c:
212 res = c.ListMessageThreads(
213 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_UNREAD)
214 )
215 assert [t.host_request.host_request_id for t in res.threads] == [request_id]
217 # after marking everything seen, the unread filter is empty
218 c.MarkAllThreadsSeen(
219 conversations_pb2.MarkAllThreadsSeenReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_ALL)
220 )
221 res = c.ListMessageThreads(
222 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_UNREAD)
223 )
224 assert len(res.threads) == 0
227def test_list_message_threads_archived_is_orthogonal(db, moderator):
228 user1, token1 = generate_user()
229 user2, token2 = generate_user()
231 chat_id = _create_group_chat(token1, [user2.id], moderator)
233 with conversations_session(token1) as c:
234 # archive the chat
235 c.SetGroupChatArchiveStatus(
236 conversations_pb2.SetGroupChatArchiveStatusReq(group_chat_id=chat_id, is_archived=True)
237 )
239 # default (non-archived) excludes it
240 res = c.ListMessageThreads(
241 conversations_pb2.ListMessageThreadsReq(
242 filter=conversations_pb2.MESSAGE_THREAD_FILTER_ALL, only_archived=False
243 )
244 )
245 assert len(res.threads) == 0
247 # only_archived=True includes it
248 res = c.ListMessageThreads(
249 conversations_pb2.ListMessageThreadsReq(
250 filter=conversations_pb2.MESSAGE_THREAD_FILTER_ALL, only_archived=True
251 )
252 )
253 assert [t.group_chat.group_chat_id for t in res.threads] == [chat_id]
256def test_list_message_threads_public_trip_offer_role_based(db, moderator):
257 traveler, traveler_token = generate_user()
258 host, host_token = generate_user()
259 _, trip_id = _make_trip(traveler.id)
261 # host offers to host the traveller's public trip (role reversal)
262 request_id = _create_host_request(host_token, traveler.id, moderator, public_trip_id=trip_id)
264 # From the offering host's view: appears under HOSTING, with correct roles
265 with conversations_session(host_token) as c:
266 res = c.ListMessageThreads(
267 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_HOSTING)
268 )
269 assert len(res.threads) == 1
270 hr = res.threads[0].host_request
271 assert hr.host_request_id == request_id
272 assert hr.HasField("public_trip_id")
273 assert hr.public_trip_id == trip_id
274 # viewer is the offering host: host_user_id == own id (viewer_is_host derived client-side)
275 assert hr.host_user_id == host.id
276 assert hr.surfer_user_id == traveler.id
278 # not under SURFING for the host
279 res = c.ListMessageThreads(
280 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_SURFING)
281 )
282 assert len(res.threads) == 0
284 # From the traveller's view: appears under SURFING and MY_PUBLIC_TRIPS, never as the surfer being the host
285 with conversations_session(traveler_token) as c:
286 res = c.ListMessageThreads(
287 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_SURFING)
288 )
289 assert [t.host_request.host_request_id for t in res.threads] == [request_id]
290 assert res.threads[0].host_request.surfer_user_id == traveler.id
291 # viewer is the traveller, not the host: host_user_id != own id
292 assert res.threads[0].host_request.host_user_id == host.id
293 assert res.threads[0].host_request.host_user_id != traveler.id
295 res = c.ListMessageThreads(
296 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_MY_PUBLIC_TRIPS)
297 )
298 assert [t.host_request.host_request_id for t in res.threads] == [request_id]
300 # also present in ALL
301 res = c.ListMessageThreads(
302 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_ALL)
303 )
304 assert request_id in {t.host_request.host_request_id for t in res.threads}
307def test_list_message_threads_public_trips_filter_gated_by_flag(db, moderator, feature_flags):
308 feature_flags.set("public_trips_enabled", False)
310 traveler, traveler_token = generate_user()
312 with conversations_session(traveler_token) as c:
313 res = c.ListMessageThreads(
314 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_MY_PUBLIC_TRIPS)
315 )
316 assert len(res.threads) == 0
317 assert not res.next_page_token
320def test_mark_all_threads_seen_respects_filter(db, moderator):
321 user1, token1 = generate_user()
322 user2, token2 = generate_user()
324 # an unread group chat (user2 messages user1) and an unread host request
325 chat_id = _create_group_chat(token2, [user1.id], moderator, text="hello there")
326 _create_host_request(token2, user1.id, moderator)
328 with conversations_session(token1) as c:
329 # mark only chats seen
330 c.MarkAllThreadsSeen(
331 conversations_pb2.MarkAllThreadsSeenReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_CHATS)
332 )
333 res = c.ListMessageThreads(
334 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_UNREAD)
335 )
336 # the chat is now read; the host request is still unread
337 remaining = [t.WhichOneof("thread") for t in res.threads]
338 assert remaining == ["host_request"]
340 # marking the host request's group chat seen does nothing more for chats
341 c.MarkAllThreadsSeen(
342 conversations_pb2.MarkAllThreadsSeenReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_ALL)
343 )
344 res = c.ListMessageThreads(
345 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_UNREAD)
346 )
347 assert len(res.threads) == 0
348 assert chat_id # referenced
351def test_ping_role_aware_counts_match_direction_without_offers(db, moderator):
352 user1, token1 = generate_user()
353 user2, token2 = generate_user()
355 # user2 surfs to user1 -> user1 is the host of the stay
356 _create_host_request(token2, user1.id, moderator)
358 with real_api_session(token1) as api:
359 res = api.Ping(api_pb2.PingReq())
360 # with no public-trip offers, role-based counts equal the direction-based ones
361 assert res.unseen_received_host_request_count == 1
362 assert res.unseen_hosting_host_request_count == 1
363 assert res.unseen_sent_host_request_count == 0
364 assert res.unseen_surfing_host_request_count == 0
365 assert res.unseen_public_trip_offer_count == 0
368def test_ping_counts_public_trip_offer_by_role(db, moderator):
369 traveler, traveler_token = generate_user()
370 host, host_token = generate_user()
371 _, trip_id = _make_trip(traveler.id)
373 request_id = _create_host_request(host_token, traveler.id, moderator, public_trip_id=trip_id)
375 # the traveller (recipient) has the offer's create message unseen: counts as SURFING + public-trip offer
376 with real_api_session(traveler_token) as api:
377 res = api.Ping(api_pb2.PingReq())
378 assert res.unseen_surfing_host_request_count == 1
379 assert res.unseen_public_trip_offer_count == 1
380 assert res.unseen_hosting_host_request_count == 0
382 # the traveller replies, so now the offering host has an unseen message under HOSTING
383 with requests_session(traveler_token) as api:
384 api.SendHostRequestMessage(
385 requests_pb2.SendHostRequestMessageReq(host_request_id=request_id, text="thanks for the offer")
386 )
388 with real_api_session(host_token) as api:
389 res = api.Ping(api_pb2.PingReq())
390 assert res.unseen_hosting_host_request_count == 1
391 assert res.unseen_surfing_host_request_count == 0
392 assert res.unseen_public_trip_offer_count == 0
395def test_ping_public_trip_offer_count_gated_by_flag(db, moderator, feature_flags):
396 feature_flags.set("public_trips_enabled", False)
398 traveler, traveler_token = generate_user()
399 host, host_token = generate_user()
400 _, trip_id = _make_trip(traveler.id)
402 _create_host_request(host_token, traveler.id, moderator, public_trip_id=trip_id)
404 with real_api_session(traveler_token) as api:
405 res = api.Ping(api_pb2.PingReq())
406 # the dedicated offer count is gated off...
407 assert res.unseen_public_trip_offer_count == 0
408 # ...but the offer is a real conversation and still surfaces under surfing
409 assert res.unseen_surfing_host_request_count == 1