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

382 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-19 00:32 +0000

1import string 

2import textwrap 

3from datetime import timedelta 

4 

5import grpc 

6import pytest 

7from sqlalchemy import select 

8 

9from couchers.db import session_scope 

10from couchers.models import ( 

11 Comment, 

12 ModerationObjectType, 

13 ModerationQueueItem, 

14 ModerationState, 

15 ModerationVisibility, 

16 Reply, 

17 Thread, 

18 User, 

19) 

20from couchers.models.discussions import CommentVersion, ContentChangeType, ReplyVersion 

21from couchers.proto import discussions_pb2, events_pb2, moderation_pb2, threads_pb2 

22from couchers.servicers.threads import pack_thread_id 

23from couchers.utils import Timestamp_from_datetime, now 

24from tests.fixtures.db import generate_user 

25from tests.fixtures.sessions import discussions_session, events_session, real_moderation_session, threads_session 

26from tests.test_communities import create_community 

27 

28 

29@pytest.fixture(autouse=True) 

30def _(testconfig): 

31 pass 

32 

33 

34def test_threads_basic(db): 

35 user1, token1 = generate_user() 

36 

37 # Create a dummy Thread (should be replaced by pages later on) 

38 with session_scope() as session: 

39 dummy_thread = Thread() 

40 session.add(dummy_thread) 

41 session.flush() 

42 PARENT_THREAD_ID = pack_thread_id(database_id=dummy_thread.id, depth=0) 

43 

44 with threads_session(token1) as api: 

45 bat_id = api.PostReply(threads_pb2.PostReplyReq(thread_id=PARENT_THREAD_ID, content="bat")).thread_id 

46 

47 cat_id = api.PostReply(threads_pb2.PostReplyReq(thread_id=PARENT_THREAD_ID, content="cat")).thread_id 

48 

49 dog_id = api.PostReply(threads_pb2.PostReplyReq(thread_id=PARENT_THREAD_ID, content="dog")).thread_id 

50 

51 dogs = [ 

52 api.PostReply(threads_pb2.PostReplyReq(thread_id=dog_id, content=animal)).thread_id 

53 for animal in ["hyena", "wolf", "prariewolf"] 

54 ] 

55 cats = [ 

56 api.PostReply(threads_pb2.PostReplyReq(thread_id=cat_id, content=animal)).thread_id 

57 for animal in ["cheetah", "lynx", "panther"] 

58 ] 

59 

60 # Make some queries 

61 ret = api.GetThread(threads_pb2.GetThreadReq(thread_id=PARENT_THREAD_ID)) 

62 assert len(ret.replies) == 3 

63 assert ret.next_page_token == "" 

64 assert ret.replies[0].thread_id == dog_id 

65 assert ret.replies[0].content == "dog" 

66 assert ret.replies[0].author_user_id == user1.id 

67 assert ret.replies[0].num_replies == 3 

68 

69 assert ret.replies[1].thread_id == cat_id 

70 assert ret.replies[1].content == "cat" 

71 assert ret.replies[1].author_user_id == user1.id 

72 assert ret.replies[1].num_replies == 3 

73 

74 assert ret.replies[2].thread_id == bat_id 

75 assert ret.replies[2].content == "bat" 

76 assert ret.replies[2].author_user_id == user1.id 

77 assert ret.replies[2].num_replies == 0 

78 

79 ret = api.GetThread(threads_pb2.GetThreadReq(thread_id=cat_id)) 

80 assert len(ret.replies) == 3 

81 assert ret.next_page_token == "" 

82 assert [reply.thread_id for reply in ret.replies] == cats[::-1] 

83 

84 ret = api.GetThread(threads_pb2.GetThreadReq(thread_id=dog_id)) 

85 assert len(ret.replies) == 3 

86 assert ret.next_page_token == "" 

87 assert [reply.thread_id for reply in ret.replies] == dogs[::-1] 

88 

89 

90def test_threads_errors(db): 

91 user1, token1 = generate_user() 

92 with threads_session(token1) as api: 

93 # request non-existing comment 

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

95 api.GetThread(threads_pb2.GetThreadReq(thread_id=11)) 

96 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

97 assert e.value.details() == "Discussion thread not found." 

98 

99 # request non-existing depth digit 

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

101 api.GetThread(threads_pb2.GetThreadReq(thread_id=19)) 

102 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

103 assert e.value.details() == "Discussion thread not found." 

104 

105 # post on non-existing comment 

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

107 api.PostReply(threads_pb2.PostReplyReq(thread_id=11, content="foo")) 

108 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

109 assert e.value.details() == "Discussion thread not found." 

110 

111 # post on non-existing depth 

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

113 api.PostReply(threads_pb2.PostReplyReq(thread_id=19, content="foo")) 

114 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

115 assert e.value.details() == "Discussion thread not found." 

116 

117 # post empty content 

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

119 api.PostReply(threads_pb2.PostReplyReq(thread_id=19, content="")) 

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

121 assert e.value.details() == "You cannot post an empty comment." 

122 

123 # post whitespace only content 

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

125 api.PostReply(threads_pb2.PostReplyReq(thread_id=19, content=" ")) 

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

127 assert e.value.details() == "You cannot post an empty comment." 

128 

129 

130def pagination_test(api, parent_id): 

131 # Post some data 

132 for c in reversed(string.ascii_lowercase): 

133 api.PostReply(threads_pb2.PostReplyReq(thread_id=parent_id, content=c)) 

134 

135 # Get it with pagination 

136 token = "" 

137 

138 for expected_page in textwrap.wrap(string.ascii_lowercase, 5): 

139 ret = api.GetThread(threads_pb2.GetThreadReq(thread_id=parent_id, page_size=5, page_token=token)) 

140 assert "".join(x.content for x in ret.replies) == expected_page 

141 token = ret.next_page_token 

142 

143 assert token == "" 

144 

145 return ret.replies[0].thread_id # to be used as a test one level deeper 

146 

147 

148def test_threads_pagination(db): 

149 user1, token1 = generate_user() 

150 

151 PARENT_THREAD_ID = 10 

152 

153 # Create a dummy Thread (should be replaced by pages later on) 

154 with session_scope() as session: 

155 session.add(Thread()) 

156 

157 with threads_session(token1) as api: 

158 comment_id = pagination_test(api, PARENT_THREAD_ID) 

159 pagination_test(api, comment_id) 

160 

161 

162def _make_thread_and_comment(token, content="hello"): 

163 """Helper: create a Thread, post a top-level Comment via the API, return (parent_thread_id, comment_thread_id).""" 

164 with session_scope() as session: 

165 thread = Thread() 

166 session.add(thread) 

167 session.flush() 

168 parent_thread_id = pack_thread_id(database_id=thread.id, depth=0) 

169 

170 with threads_session(token) as api: 

171 comment_thread_id = api.PostReply( 

172 threads_pb2.PostReplyReq(thread_id=parent_thread_id, content=content) 

173 ).thread_id 

174 

175 return parent_thread_id, comment_thread_id 

176 

177 

178def test_comment_creates_moderation_state(db): 

179 """Posting a comment creates a ModerationState (shadowed) and an initial-review queue item.""" 

180 user, token = generate_user() 

181 _, comment_thread_id = _make_thread_and_comment(token) 

182 comment_db_id = comment_thread_id // 10 

183 

184 with session_scope() as session: 

185 comment = session.execute(select(Comment).where(Comment.id == comment_db_id)).scalar_one() 

186 

187 state = session.execute( 

188 select(ModerationState).where(ModerationState.id == comment.moderation_state_id) 

189 ).scalar_one() 

190 assert state.object_type == ModerationObjectType.comment 

191 assert state.object_id == comment.id 

192 assert state.visibility == ModerationVisibility.shadowed 

193 

194 queue_item = session.execute( 

195 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id) 

196 ).scalar_one() 

197 assert queue_item.resolved_by_log_id is None 

198 

199 

200def test_reply_creates_moderation_state(db): 

201 """Posting a reply to a comment creates its own ModerationState.""" 

202 user, token = generate_user() 

203 _, comment_thread_id = _make_thread_and_comment(token) 

204 

205 with threads_session(token) as api: 

206 reply_thread_id = api.PostReply( 

207 threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="reply text") 

208 ).thread_id 

209 reply_db_id = reply_thread_id // 10 

210 

211 with session_scope() as session: 

212 reply = session.execute(select(Reply).where(Reply.id == reply_db_id)).scalar_one() 

213 

214 state = session.execute( 

215 select(ModerationState).where(ModerationState.id == reply.moderation_state_id) 

216 ).scalar_one() 

217 assert state.object_type == ModerationObjectType.reply 

218 assert state.object_id == reply.id 

219 assert state.visibility == ModerationVisibility.shadowed 

220 

221 

222def test_shadowed_comment_visible_to_author_only(db): 

223 """A shadowed comment is visible to its author but not to other users.""" 

224 author, author_token = generate_user() 

225 other, other_token = generate_user() 

226 

227 parent_thread_id, _ = _make_thread_and_comment(author_token, content="secret") 

228 

229 # Author sees their own shadowed comment 

230 with threads_session(author_token) as api: 

231 ret = api.GetThread(threads_pb2.GetThreadReq(thread_id=parent_thread_id)) 

232 assert len(ret.replies) == 1 

233 assert ret.replies[0].content == "secret" 

234 

235 # Other user does not see the shadowed comment 

236 with threads_session(other_token) as api: 

237 ret = api.GetThread(threads_pb2.GetThreadReq(thread_id=parent_thread_id)) 

238 assert len(ret.replies) == 0 

239 

240 

241def test_shadowed_reply_visible_to_author_only(db): 

242 """A shadowed reply is visible to its author but not to other users.""" 

243 author, author_token = generate_user() 

244 other, other_token = generate_user() 

245 

246 _, comment_thread_id = _make_thread_and_comment(author_token, content="hi") 

247 # Approve the comment so the parent comment is visible to others (otherwise they can't see the comment context anyway) 

248 comment_db_id = comment_thread_id // 10 

249 with session_scope() as session: 

250 comment = session.execute(select(Comment).where(Comment.id == comment_db_id)).scalar_one() 

251 state = session.execute( 

252 select(ModerationState).where(ModerationState.id == comment.moderation_state_id) 

253 ).scalar_one() 

254 state.visibility = ModerationVisibility.visible 

255 

256 with threads_session(author_token) as api: 

257 api.PostReply(threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="my reply")) 

258 

259 # Author sees their own shadowed reply 

260 with threads_session(author_token) as api: 

261 ret = api.GetThread(threads_pb2.GetThreadReq(thread_id=comment_thread_id)) 

262 assert len(ret.replies) == 1 

263 assert ret.replies[0].content == "my reply" 

264 

265 # Other user does not see the shadowed reply 

266 with threads_session(other_token) as api: 

267 ret = api.GetThread(threads_pb2.GetThreadReq(thread_id=comment_thread_id)) 

268 assert len(ret.replies) == 0 

269 

270 

271def _approve(session, moderation_state_id): 

272 session.execute( 

273 select(ModerationState).where(ModerationState.id == moderation_state_id) 

274 ).scalar_one().visibility = ModerationVisibility.visible 

275 

276 

277def test_comment_by_invisible_user_hidden(db): 

278 """A comment by a deleted/banned user is hidden from others even when its moderation state is visible.""" 

279 author, author_token = generate_user() 

280 other, other_token = generate_user() 

281 

282 parent_thread_id, comment_thread_id = _make_thread_and_comment(author_token, content="from invisible user") 

283 comment_db_id = comment_thread_id // 10 

284 

285 with session_scope() as session: 

286 comment = session.execute(select(Comment).where(Comment.id == comment_db_id)).scalar_one() 

287 _approve(session, comment.moderation_state_id) 

288 

289 # while the author is visible, the comment shows 

290 with threads_session(other_token) as api: 

291 assert len(api.GetThread(threads_pb2.GetThreadReq(thread_id=parent_thread_id)).replies) == 1 

292 

293 # delete the author 

294 with session_scope() as session: 

295 session.execute(select(User).where(User.id == author.id)).scalar_one().deleted_at = now() 

296 

297 # the comment is now hidden from other users 

298 with threads_session(other_token) as api: 

299 assert len(api.GetThread(threads_pb2.GetThreadReq(thread_id=parent_thread_id)).replies) == 0 

300 

301 

302def test_reply_by_invisible_user_hidden(db): 

303 """A reply by a deleted/banned user is hidden from others even when its moderation state is visible.""" 

304 commenter, commenter_token = generate_user() 

305 replier, replier_token = generate_user() 

306 viewer, viewer_token = generate_user() 

307 

308 # comment by a user who stays visible, so the parent comment can still be navigated to 

309 parent_thread_id, comment_thread_id = _make_thread_and_comment(commenter_token, content="hi") 

310 comment_db_id = comment_thread_id // 10 

311 with session_scope() as session: 

312 comment = session.execute(select(Comment).where(Comment.id == comment_db_id)).scalar_one() 

313 _approve(session, comment.moderation_state_id) 

314 

315 with threads_session(replier_token) as api: 

316 reply_thread_id = api.PostReply( 

317 threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="my reply") 

318 ).thread_id 

319 reply_db_id = reply_thread_id // 10 

320 with session_scope() as session: 

321 reply = session.execute(select(Reply).where(Reply.id == reply_db_id)).scalar_one() 

322 _approve(session, reply.moderation_state_id) 

323 

324 # while the replier is visible, the reply shows and is counted on the parent comment 

325 with threads_session(viewer_token) as api: 

326 assert len(api.GetThread(threads_pb2.GetThreadReq(thread_id=comment_thread_id)).replies) == 1 

327 parent = api.GetThread(threads_pb2.GetThreadReq(thread_id=parent_thread_id)) 

328 assert parent.replies[0].num_replies == 1 

329 

330 # delete the replier 

331 with session_scope() as session: 

332 session.execute(select(User).where(User.id == replier.id)).scalar_one().deleted_at = now() 

333 

334 # the reply is now hidden from other users and no longer counted on the parent comment 

335 with threads_session(viewer_token) as api: 

336 assert len(api.GetThread(threads_pb2.GetThreadReq(thread_id=comment_thread_id)).replies) == 0 

337 parent = api.GetThread(threads_pb2.GetThreadReq(thread_id=parent_thread_id)) 

338 assert parent.replies[0].num_replies == 0 

339 

340 

341def test_admin_can_approve_comment(db): 

342 """A moderator can approve a comment via ModerateContent and make it visible to other users.""" 

343 author, author_token = generate_user() 

344 other, other_token = generate_user() 

345 _moderator, moderator_token = generate_user(is_superuser=True) 

346 

347 parent_thread_id, comment_thread_id = _make_thread_and_comment(author_token, content="approved comment") 

348 comment_db_id = comment_thread_id // 10 

349 

350 with session_scope() as session: 

351 comment = session.execute(select(Comment).where(Comment.id == comment_db_id)).scalar_one() 

352 state_id = comment.moderation_state_id 

353 

354 # Other user can't see it yet 

355 with threads_session(other_token) as api: 

356 assert len(api.GetThread(threads_pb2.GetThreadReq(thread_id=parent_thread_id)).replies) == 0 

357 

358 # Moderator approves 

359 with real_moderation_session(moderator_token) as api: 

360 api.ModerateContent( 

361 moderation_pb2.ModerateContentReq( 

362 moderation_state_id=state_id, 

363 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

364 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

365 reason="Looks good", 

366 ) 

367 ) 

368 

369 # Now other user sees it 

370 with threads_session(other_token) as api: 

371 ret = api.GetThread(threads_pb2.GetThreadReq(thread_id=parent_thread_id)) 

372 assert len(ret.replies) == 1 

373 assert ret.replies[0].content == "approved comment" 

374 

375 

376def test_admin_can_hide_comment(db): 

377 """A moderator can hide an approved comment, removing it from non-author views.""" 

378 author, author_token = generate_user() 

379 other, other_token = generate_user() 

380 _moderator, moderator_token = generate_user(is_superuser=True) 

381 

382 parent_thread_id, comment_thread_id = _make_thread_and_comment(author_token, content="bad comment") 

383 comment_db_id = comment_thread_id // 10 

384 

385 with session_scope() as session: 

386 comment = session.execute(select(Comment).where(Comment.id == comment_db_id)).scalar_one() 

387 state_id = comment.moderation_state_id 

388 # Pretend the comment was previously approved 

389 state = session.execute(select(ModerationState).where(ModerationState.id == state_id)).scalar_one() 

390 state.visibility = ModerationVisibility.visible 

391 

392 # Other user sees it 

393 with threads_session(other_token) as api: 

394 assert len(api.GetThread(threads_pb2.GetThreadReq(thread_id=parent_thread_id)).replies) == 1 

395 

396 # Moderator hides it 

397 with real_moderation_session(moderator_token) as api: 

398 api.ModerateContent( 

399 moderation_pb2.ModerateContentReq( 

400 moderation_state_id=state_id, 

401 action=moderation_pb2.MODERATION_ACTION_HIDE, 

402 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

403 reason="Inappropriate", 

404 ) 

405 ) 

406 

407 # Other user no longer sees it 

408 with threads_session(other_token) as api: 

409 assert len(api.GetThread(threads_pb2.GetThreadReq(thread_id=parent_thread_id)).replies) == 0 

410 # Author also no longer sees it (hidden, not shadowed) 

411 with threads_session(author_token) as api: 

412 assert len(api.GetThread(threads_pb2.GetThreadReq(thread_id=parent_thread_id)).replies) == 0 

413 

414 

415def test_total_num_responses_excludes_shadowed(db): 

416 from couchers.context import make_background_user_context # noqa: PLC0415 

417 from couchers.servicers.threads import total_num_responses # noqa: PLC0415 

418 

419 author, author_token = generate_user() 

420 viewer, _ = generate_user() 

421 parent_thread_id, _ = _make_thread_and_comment(author_token, content="one") 

422 viewer_context = make_background_user_context(user_id=viewer.id) 

423 

424 parent_db_id, _ = divmod(parent_thread_id, 10) 

425 

426 with session_scope() as session: 

427 assert total_num_responses(session, viewer_context, parent_db_id) == 0 

428 

429 with session_scope() as session: 

430 state = session.execute( 

431 select(ModerationState).where( 

432 ModerationState.object_type == ModerationObjectType.comment, 

433 ModerationState.object_id == session.execute(select(Comment.id)).scalar_one(), 

434 ) 

435 ).scalar_one() 

436 state.visibility = ModerationVisibility.visible 

437 

438 with session_scope() as session: 

439 assert total_num_responses(session, viewer_context, parent_db_id) == 1 

440 

441 

442def test_total_num_responses_includes_own_shadowed(db): 

443 """The count uses the viewer's context so authors see their own shadowed content in the total, 

444 matching what GetThread shows them in the list.""" 

445 from couchers.context import make_background_user_context # noqa: PLC0415 

446 from couchers.servicers.threads import total_num_responses # noqa: PLC0415 

447 

448 author, author_token = generate_user() 

449 parent_thread_id, _ = _make_thread_and_comment(author_token, content="one") 

450 author_context = make_background_user_context(user_id=author.id) 

451 

452 parent_db_id, _ = divmod(parent_thread_id, 10) 

453 

454 with session_scope() as session: 

455 assert total_num_responses(session, author_context, parent_db_id) == 1 

456 

457 

458def test_edit_comment_creates_version_record(db): 

459 user, token = generate_user() 

460 parent_thread_id, comment_thread_id = _make_thread_and_comment(token, content="original comment") 

461 comment_db_id = comment_thread_id // 10 

462 

463 with threads_session(token) as api: 

464 api.UpdateReply(threads_pb2.UpdateReplyReq(thread_id=comment_thread_id, content="edited comment")) 

465 

466 with session_scope() as session: 

467 versions = ( 

468 session.execute(select(CommentVersion).where(CommentVersion.comment_id == comment_db_id)).scalars().all() 

469 ) 

470 assert len(versions) == 1 

471 v = versions[0] 

472 assert v.change_type == ContentChangeType.edit 

473 assert v.old_content == "original comment" 

474 assert v.new_content == "edited comment" 

475 assert v.editor_user_id == user.id 

476 

477 

478def test_delete_comment_creates_version_record(db): 

479 user, token = generate_user() 

480 parent_thread_id, comment_thread_id = _make_thread_and_comment(token, content="comment to delete") 

481 comment_db_id = comment_thread_id // 10 

482 

483 with threads_session(token) as api: 

484 api.DeleteReply(threads_pb2.DeleteReplyReq(thread_id=comment_thread_id)) 

485 

486 with session_scope() as session: 

487 versions = ( 

488 session.execute(select(CommentVersion).where(CommentVersion.comment_id == comment_db_id)).scalars().all() 

489 ) 

490 assert len(versions) == 1 

491 v = versions[0] 

492 assert v.change_type == ContentChangeType.delete 

493 assert v.old_content == "comment to delete" 

494 assert v.new_content is None 

495 assert v.editor_user_id == user.id 

496 

497 

498def test_edit_reply_creates_version_record(db): 

499 user, token = generate_user() 

500 _, comment_thread_id = _make_thread_and_comment(token, content="a comment") 

501 

502 with threads_session(token) as api: 

503 reply_thread_id = api.PostReply( 

504 threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="original reply") 

505 ).thread_id 

506 reply_db_id = reply_thread_id // 10 

507 

508 with threads_session(token) as api: 

509 api.UpdateReply(threads_pb2.UpdateReplyReq(thread_id=reply_thread_id, content="edited reply")) 

510 

511 with session_scope() as session: 

512 versions = session.execute(select(ReplyVersion).where(ReplyVersion.reply_id == reply_db_id)).scalars().all() 

513 assert len(versions) == 1 

514 v = versions[0] 

515 assert v.change_type == ContentChangeType.edit 

516 assert v.old_content == "original reply" 

517 assert v.new_content == "edited reply" 

518 assert v.editor_user_id == user.id 

519 

520 

521def test_delete_reply_creates_version_record(db): 

522 user, token = generate_user() 

523 _, comment_thread_id = _make_thread_and_comment(token, content="a comment") 

524 

525 with threads_session(token) as api: 

526 reply_thread_id = api.PostReply( 

527 threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="reply to delete") 

528 ).thread_id 

529 reply_db_id = reply_thread_id // 10 

530 

531 with threads_session(token) as api: 

532 api.DeleteReply(threads_pb2.DeleteReplyReq(thread_id=reply_thread_id)) 

533 

534 with session_scope() as session: 

535 versions = session.execute(select(ReplyVersion).where(ReplyVersion.reply_id == reply_db_id)).scalars().all() 

536 assert len(versions) == 1 

537 v = versions[0] 

538 assert v.change_type == ContentChangeType.delete 

539 assert v.old_content == "reply to delete" 

540 assert v.new_content is None 

541 assert v.editor_user_id == user.id 

542 

543 

544def _create_event_and_get_thread_id(organizer, organizer_token: str) -> int: 

545 """Helper: create an Event via the API, return its (packed) thread_id.""" 

546 with session_scope() as session: 

547 create_community(session, 0, 2, "Testing Community", [organizer], [], None) 

548 

549 start_time = now() + timedelta(hours=2) 

550 end_time = start_time + timedelta(hours=3) 

551 with events_session(organizer_token) as api: 

552 res = api.CreateEvent( 

553 events_pb2.CreateEventReq( 

554 title="Dummy event", 

555 content="Dummy content.", 

556 location=events_pb2.EventLocation( 

557 address="Near Null Island", 

558 lat=0.1, 

559 lng=0.2, 

560 ), 

561 start_time=Timestamp_from_datetime(start_time), 

562 end_time=Timestamp_from_datetime(end_time), 

563 timezone="UTC", 

564 ) 

565 ) 

566 return int(res.thread.thread_id) 

567 

568 

569def test_post_reply_incomplete_profile_blocked_on_event_comment(db): 

570 """An incomplete-profile user cannot post a top-level comment on an event's thread.""" 

571 organizer, organizer_token = generate_user() 

572 _commenter, commenter_token = generate_user(complete_profile=False) 

573 

574 event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token) 

575 

576 with threads_session(commenter_token) as api: 

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

578 api.PostReply(threads_pb2.PostReplyReq(thread_id=event_thread_id, content="hello")) 

579 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

580 

581 

582def test_post_reply_incomplete_profile_blocked_on_event_reply(db): 

583 """An incomplete-profile user cannot post a nested reply within an event's thread.""" 

584 organizer, organizer_token = generate_user() 

585 _commenter, commenter_token = generate_user() 

586 _replier, replier_token = generate_user(complete_profile=False) 

587 

588 event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token) 

589 

590 with threads_session(commenter_token) as api: 

591 comment_thread_id = api.PostReply( 

592 threads_pb2.PostReplyReq(thread_id=event_thread_id, content="top-level comment") 

593 ).thread_id 

594 

595 with threads_session(replier_token) as api: 

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

597 api.PostReply(threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="a reply")) 

598 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

599 

600 

601def test_post_reply_complete_profile_allowed_on_event_comment(db): 

602 """A complete-profile user can post a comment on an event's thread.""" 

603 organizer, organizer_token = generate_user() 

604 _commenter, commenter_token = generate_user() 

605 

606 event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token) 

607 

608 with threads_session(commenter_token) as api: 

609 comment_thread_id = api.PostReply( 

610 threads_pb2.PostReplyReq(thread_id=event_thread_id, content="hello") 

611 ).thread_id 

612 assert comment_thread_id 

613 

614 

615def test_post_reply_incomplete_profile_blocked_on_discussion_thread(db): 

616 """An incomplete-profile user cannot post a comment on a discussion thread.""" 

617 admin, admin_token = generate_user() 

618 _commenter, commenter_token = generate_user(complete_profile=False) 

619 

620 with session_scope() as session: 

621 community_id = create_community(session, 0, 1, "Testing Community", [admin], [], None).id 

622 

623 with discussions_session(admin_token) as api: 

624 discussion = api.CreateDiscussion( 

625 discussions_pb2.CreateDiscussionReq( 

626 title="A discussion", 

627 content="Content", 

628 owner_community_id=community_id, 

629 ) 

630 ) 

631 discussion_thread_id = discussion.thread.thread_id 

632 

633 with threads_session(commenter_token) as api: 

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

635 api.PostReply(threads_pb2.PostReplyReq(thread_id=discussion_thread_id, content="hello")) 

636 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

637 

638 

639def test_edit_comment_multiple_edits_creates_multiple_version_records(db): 

640 user, token = generate_user() 

641 _, comment_thread_id = _make_thread_and_comment(token, content="v1") 

642 comment_db_id = comment_thread_id // 10 

643 

644 with threads_session(token) as api: 

645 api.UpdateReply(threads_pb2.UpdateReplyReq(thread_id=comment_thread_id, content="v2")) 

646 api.UpdateReply(threads_pb2.UpdateReplyReq(thread_id=comment_thread_id, content="v3")) 

647 

648 with session_scope() as session: 

649 versions = ( 

650 session.execute( 

651 select(CommentVersion).where(CommentVersion.comment_id == comment_db_id).order_by(CommentVersion.id) 

652 ) 

653 .scalars() 

654 .all() 

655 ) 

656 assert len(versions) == 2 

657 assert versions[0].old_content == "v1" 

658 assert versions[0].new_content == "v2" 

659 assert versions[1].old_content == "v2" 

660 assert versions[1].new_content == "v3"