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

1from datetime import timedelta 

2 

3import grpc 

4import pytest 

5 

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 

13 

14 

15@pytest.fixture(autouse=True) 

16def _(testconfig): 

17 pass 

18 

19 

20HOST_REQUEST_TEXT = "a" * 300 

21 

22 

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 

53 

54 

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) 

61 

62 

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) 

76 

77 

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() 

83 

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

85 

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 ) 

95 

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 ) 

106 

107 

108def test_list_message_threads_interleaves_chats_and_requests(db, moderator): 

109 user1, token1 = generate_user() 

110 user2, token2 = generate_user() 

111 

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) 

115 

116 with conversations_session(token1) as c: 

117 res = c.ListMessageThreads( 

118 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_ALL) 

119 ) 

120 

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" 

131 

132 

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)] 

136 

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)) 

142 

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 

163 

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) 

169 

170 

171def test_list_message_threads_chats_filter_excludes_host_requests(db, moderator): 

172 user1, token1 = generate_user() 

173 user2, token2 = generate_user() 

174 

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

176 _create_host_request(token2, user1.id, moderator) 

177 

178 with conversations_session(token1) as c: 

179 res = c.ListMessageThreads( 

180 conversations_pb2.ListMessageThreadsReq(filter=conversations_pb2.MESSAGE_THREAD_FILTER_CHATS) 

181 ) 

182 

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

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

185 

186 

187def test_message_threads_reject_unspecified_filter(db): 

188 user1, token1 = generate_user() 

189 

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 

196 

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 

202 

203 

204def test_list_message_threads_unread_filter(db, moderator): 

205 user1, token1 = generate_user() 

206 user2, token2 = generate_user() 

207 

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

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

210 

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] 

216 

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 

225 

226 

227def test_list_message_threads_archived_is_orthogonal(db, moderator): 

228 user1, token1 = generate_user() 

229 user2, token2 = generate_user() 

230 

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

232 

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 ) 

238 

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 

246 

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] 

254 

255 

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) 

260 

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) 

263 

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 

277 

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 

283 

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 

294 

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] 

299 

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} 

305 

306 

307def test_list_message_threads_public_trips_filter_gated_by_flag(db, moderator, feature_flags): 

308 feature_flags.set("public_trips_enabled", False) 

309 

310 traveler, traveler_token = generate_user() 

311 

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 

318 

319 

320def test_mark_all_threads_seen_respects_filter(db, moderator): 

321 user1, token1 = generate_user() 

322 user2, token2 = generate_user() 

323 

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) 

327 

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"] 

339 

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 

349 

350 

351def test_ping_role_aware_counts_match_direction_without_offers(db, moderator): 

352 user1, token1 = generate_user() 

353 user2, token2 = generate_user() 

354 

355 # user2 surfs to user1 -> user1 is the host of the stay 

356 _create_host_request(token2, user1.id, moderator) 

357 

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 

366 

367 

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) 

372 

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

374 

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 

381 

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 ) 

387 

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 

393 

394 

395def test_ping_public_trip_offer_count_gated_by_flag(db, moderator, feature_flags): 

396 feature_flags.set("public_trips_enabled", False) 

397 

398 traveler, traveler_token = generate_user() 

399 host, host_token = generate_user() 

400 _, trip_id = _make_trip(traveler.id) 

401 

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

403 

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