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

391 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 15:47 +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 datetime_to_iso8601_local, now 

24from tests.fixtures.db import generate_user 

25from tests.fixtures.misc import Moderator 

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

27from tests.test_communities import create_community 

28 

29 

30def test_threads_basic(db): 

31 user1, token1 = generate_user() 

32 

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

34 with session_scope() as session: 

35 dummy_thread = Thread() 

36 session.add(dummy_thread) 

37 session.flush() 

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

39 

40 with threads_session(token1) as api: 

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

42 

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

44 

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

46 

47 dogs = [ 

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

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

50 ] 

51 cats = [ 

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

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

54 ] 

55 

56 # Make some queries 

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

58 assert len(ret.replies) == 3 

59 assert ret.next_page_token == "" 

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

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

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

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

64 

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

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

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

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

69 

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

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

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

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

74 

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

76 assert len(ret.replies) == 3 

77 assert ret.next_page_token == "" 

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

79 

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

81 assert len(ret.replies) == 3 

82 assert ret.next_page_token == "" 

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

84 

85 

86def test_threads_errors(db): 

87 user1, token1 = generate_user() 

88 with threads_session(token1) as api: 

89 # request non-existing comment 

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

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

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

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

94 

95 # request non-existing depth digit 

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

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

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

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

100 

101 # post on non-existing comment 

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

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

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

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

106 

107 # post on non-existing depth 

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

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

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

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

112 

113 # post empty content 

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

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

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

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

118 

119 # post whitespace only content 

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

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

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

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

124 

125 

126def pagination_test(api, parent_id): 

127 # Post some data 

128 for c in reversed(string.ascii_lowercase): 

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

130 

131 # Get it with pagination 

132 token = "" 

133 

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

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

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

137 token = ret.next_page_token 

138 

139 assert token == "" 

140 

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

142 

143 

144def test_threads_pagination(db): 

145 user1, token1 = generate_user() 

146 

147 PARENT_THREAD_ID = 10 

148 

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

150 with session_scope() as session: 

151 session.add(Thread()) 

152 

153 with threads_session(token1) as api: 

154 comment_id = pagination_test(api, PARENT_THREAD_ID) 

155 pagination_test(api, comment_id) 

156 

157 

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

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

160 with session_scope() as session: 

161 thread = Thread() 

162 session.add(thread) 

163 session.flush() 

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

165 

166 with threads_session(token) as api: 

167 comment_thread_id = api.PostReply( 

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

169 ).thread_id 

170 

171 return parent_thread_id, comment_thread_id 

172 

173 

174def test_comment_creates_moderation_state(db): 

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

176 user, token = generate_user() 

177 _, comment_thread_id = _make_thread_and_comment(token) 

178 comment_db_id = comment_thread_id // 10 

179 

180 with session_scope() as session: 

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

182 

183 state = session.execute( 

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

185 ).scalar_one() 

186 assert state.object_type == ModerationObjectType.comment 

187 assert state.object_id == comment.id 

188 assert state.visibility == ModerationVisibility.shadowed 

189 

190 queue_item = session.execute( 

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

192 ).scalar_one() 

193 assert queue_item.resolved_by_log_id is None 

194 

195 

196def test_reply_creates_moderation_state(db): 

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

198 user, token = generate_user() 

199 _, comment_thread_id = _make_thread_and_comment(token) 

200 

201 with threads_session(token) as api: 

202 reply_thread_id = api.PostReply( 

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

204 ).thread_id 

205 reply_db_id = reply_thread_id // 10 

206 

207 with session_scope() as session: 

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

209 

210 state = session.execute( 

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

212 ).scalar_one() 

213 assert state.object_type == ModerationObjectType.reply 

214 assert state.object_id == reply.id 

215 assert state.visibility == ModerationVisibility.shadowed 

216 

217 

218def test_shadowed_comment_visible_to_author_only(db): 

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

220 author, author_token = generate_user() 

221 other, other_token = generate_user() 

222 

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

224 

225 # Author sees their own shadowed comment 

226 with threads_session(author_token) as api: 

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

228 assert len(ret.replies) == 1 

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

230 

231 # Other user does not see the shadowed comment 

232 with threads_session(other_token) as api: 

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

234 assert len(ret.replies) == 0 

235 

236 

237def test_shadowed_reply_visible_to_author_only(db, moderator: Moderator): 

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

239 author, author_token = generate_user() 

240 other, other_token = generate_user() 

241 

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

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

244 moderator.approve_thread_post(comment_thread_id) 

245 

246 with threads_session(author_token) as api: 

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

248 

249 # Author sees their own shadowed reply 

250 with threads_session(author_token) as api: 

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

252 assert len(ret.replies) == 1 

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

254 

255 # Other user does not see the shadowed reply 

256 with threads_session(other_token) as api: 

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

258 assert len(ret.replies) == 0 

259 

260 

261def test_comment_by_invisible_user_hidden(db, moderator: Moderator): 

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

263 author, author_token = generate_user() 

264 other, other_token = generate_user() 

265 

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

267 moderator.approve_thread_post(comment_thread_id) 

268 

269 # while the author is visible, the comment shows 

270 with threads_session(other_token) as api: 

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

272 

273 # delete the author 

274 with session_scope() as session: 

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

276 

277 # the comment is now hidden from other users 

278 with threads_session(other_token) as api: 

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

280 

281 

282def test_reply_by_invisible_user_hidden(db, moderator: Moderator): 

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

284 commenter, commenter_token = generate_user() 

285 replier, replier_token = generate_user() 

286 viewer, viewer_token = generate_user() 

287 

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

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

290 moderator.approve_thread_post(comment_thread_id) 

291 

292 with threads_session(replier_token) as api: 

293 reply_thread_id = api.PostReply( 

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

295 ).thread_id 

296 moderator.approve_thread_post(reply_thread_id) 

297 

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

299 with threads_session(viewer_token) as api: 

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

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

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

303 

304 # delete the replier 

305 with session_scope() as session: 

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

307 

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

309 with threads_session(viewer_token) as api: 

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

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

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

313 

314 

315def test_admin_can_approve_comment(db): 

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

317 author, author_token = generate_user() 

318 other, other_token = generate_user() 

319 _moderator, moderator_token = generate_user(is_superuser=True) 

320 

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

322 comment_db_id = comment_thread_id // 10 

323 

324 with session_scope() as session: 

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

326 state_id = comment.moderation_state_id 

327 

328 # Other user can't see it yet 

329 with threads_session(other_token) as api: 

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

331 

332 # Moderator approves 

333 with real_moderation_session(moderator_token) as api: 

334 api.ModerateContent( 

335 moderation_pb2.ModerateContentReq( 

336 moderation_state_id=state_id, 

337 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

338 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

339 reason="Looks good", 

340 ) 

341 ) 

342 

343 # Now other user sees it 

344 with threads_session(other_token) as api: 

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

346 assert len(ret.replies) == 1 

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

348 

349 

350def test_admin_can_hide_comment(db, moderator: Moderator): 

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

352 author, author_token = generate_user() 

353 other, other_token = generate_user() 

354 

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

356 moderator.approve_thread_post(comment_thread_id) 

357 

358 # Other user sees it 

359 with threads_session(other_token) as api: 

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

361 

362 moderator.set_thread_post_visibility(comment_thread_id, moderation_pb2.MODERATION_VISIBILITY_HIDDEN) 

363 

364 # Other user no longer sees it 

365 with threads_session(other_token) as api: 

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

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

368 with threads_session(author_token) as api: 

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

370 

371 

372def test_total_num_responses_excludes_shadowed(db, moderator: Moderator): 

373 from couchers.context import make_background_user_context # noqa: PLC0415 

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

375 

376 author, author_token = generate_user() 

377 viewer, _ = generate_user() 

378 parent_thread_id, comment_thread_id = _make_thread_and_comment(author_token, content="one") 

379 viewer_context = make_background_user_context(user_id=viewer.id) 

380 

381 parent_db_id, _ = divmod(parent_thread_id, 10) 

382 

383 with session_scope() as session: 

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

385 

386 moderator.approve_thread_post(comment_thread_id) 

387 

388 with session_scope() as session: 

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

390 

391 

392def test_total_num_responses_includes_own_shadowed(db): 

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

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

395 from couchers.context import make_background_user_context # noqa: PLC0415 

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

397 

398 author, author_token = generate_user() 

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

400 author_context = make_background_user_context(user_id=author.id) 

401 

402 parent_db_id, _ = divmod(parent_thread_id, 10) 

403 

404 with session_scope() as session: 

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

406 

407 

408def test_total_num_responses_excludes_replies_under_hidden_comment(db, moderator: Moderator): 

409 """A reply only counts while the comment it hangs off is visible.""" 

410 from couchers.context import make_background_user_context # noqa: PLC0415 

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

412 

413 author, author_token = generate_user() 

414 viewer, viewer_token = generate_user() 

415 parent_thread_id, comment_thread_id = _make_thread_and_comment(author_token, content="comment") 

416 viewer_context = make_background_user_context(user_id=viewer.id) 

417 parent_db_id, _ = divmod(parent_thread_id, 10) 

418 

419 with threads_session(author_token) as api: 

420 reply_thread_id = api.PostReply( 

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

422 ).thread_id 

423 

424 moderator.approve_thread_post(comment_thread_id) 

425 moderator.approve_thread_post(reply_thread_id) 

426 

427 with session_scope() as session: 

428 assert total_num_responses(session, viewer_context, parent_db_id) == 2 

429 

430 moderator.set_thread_post_visibility(comment_thread_id, moderation_pb2.MODERATION_VISIBILITY_HIDDEN) 

431 

432 with threads_session(viewer_token) as api: 

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

434 with session_scope() as session: 

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

436 

437 

438def test_total_num_responses_excludes_replies_under_someone_elses_shadowed_comment(db, moderator: Moderator): 

439 """A reply's own author doesn't count it while the comment above it is still shadowed to them.""" 

440 from couchers.context import make_background_user_context # noqa: PLC0415 

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

442 

443 author, author_token = generate_user() 

444 replier, replier_token = generate_user() 

445 parent_thread_id, comment_thread_id = _make_thread_and_comment(author_token, content="comment") 

446 replier_context = make_background_user_context(user_id=replier.id) 

447 parent_db_id, _ = divmod(parent_thread_id, 10) 

448 

449 with threads_session(replier_token) as api: 

450 reply_thread_id = api.PostReply( 

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

452 ).thread_id 

453 

454 moderator.approve_thread_post(reply_thread_id) 

455 

456 with session_scope() as session: 

457 assert total_num_responses(session, replier_context, parent_db_id) == 0 

458 

459 moderator.approve_thread_post(comment_thread_id) 

460 

461 with session_scope() as session: 

462 assert total_num_responses(session, replier_context, parent_db_id) == 2 

463 

464 

465def test_edit_comment_creates_version_record(db): 

466 user, token = generate_user() 

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

468 comment_db_id = comment_thread_id // 10 

469 

470 with threads_session(token) as api: 

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

472 

473 with session_scope() as session: 

474 versions = ( 

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

476 ) 

477 assert len(versions) == 1 

478 v = versions[0] 

479 assert v.change_type == ContentChangeType.edit 

480 assert v.old_content == "original comment" 

481 assert v.new_content == "edited comment" 

482 assert v.editor_user_id == user.id 

483 

484 

485def test_delete_comment_creates_version_record(db): 

486 user, token = generate_user() 

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

488 comment_db_id = comment_thread_id // 10 

489 

490 with threads_session(token) as api: 

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

492 

493 with session_scope() as session: 

494 versions = ( 

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

496 ) 

497 assert len(versions) == 1 

498 v = versions[0] 

499 assert v.change_type == ContentChangeType.delete 

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

501 assert v.new_content is None 

502 assert v.editor_user_id == user.id 

503 

504 

505def test_edit_reply_creates_version_record(db): 

506 user, token = generate_user() 

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

508 

509 with threads_session(token) as api: 

510 reply_thread_id = api.PostReply( 

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

512 ).thread_id 

513 reply_db_id = reply_thread_id // 10 

514 

515 with threads_session(token) as api: 

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

517 

518 with session_scope() as session: 

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

520 assert len(versions) == 1 

521 v = versions[0] 

522 assert v.change_type == ContentChangeType.edit 

523 assert v.old_content == "original reply" 

524 assert v.new_content == "edited reply" 

525 assert v.editor_user_id == user.id 

526 

527 

528def test_delete_reply_creates_version_record(db): 

529 user, token = generate_user() 

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

531 

532 with threads_session(token) as api: 

533 reply_thread_id = api.PostReply( 

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

535 ).thread_id 

536 reply_db_id = reply_thread_id // 10 

537 

538 with threads_session(token) as api: 

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

540 

541 with session_scope() as session: 

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

543 assert len(versions) == 1 

544 v = versions[0] 

545 assert v.change_type == ContentChangeType.delete 

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

547 assert v.new_content is None 

548 assert v.editor_user_id == user.id 

549 

550 

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

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

553 with session_scope() as session: 

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

555 

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

557 end_time = start_time + timedelta(hours=3) 

558 with events_session(organizer_token) as api: 

559 res = api.CreateEvent( 

560 events_pb2.CreateEventReq( 

561 title="Dummy event", 

562 content="Dummy content.", 

563 location=events_pb2.EventLocation( 

564 address="Near Null Island", 

565 lat=0.1, 

566 lng=0.2, 

567 ), 

568 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time), 

569 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time), 

570 ) 

571 ) 

572 return int(res.thread.thread_id) 

573 

574 

575def test_post_reply_incomplete_profile_blocked_on_event_comment(db): 

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

577 organizer, organizer_token = generate_user() 

578 _commenter, commenter_token = generate_user(complete_profile=False) 

579 

580 event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token) 

581 

582 with threads_session(commenter_token) as api: 

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

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

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

586 

587 

588def test_post_reply_incomplete_profile_blocked_on_event_reply(db): 

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

590 organizer, organizer_token = generate_user() 

591 _commenter, commenter_token = generate_user() 

592 _replier, replier_token = generate_user(complete_profile=False) 

593 

594 event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token) 

595 

596 with threads_session(commenter_token) as api: 

597 comment_thread_id = api.PostReply( 

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

599 ).thread_id 

600 

601 with threads_session(replier_token) as api: 

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

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

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

605 

606 

607def test_post_reply_complete_profile_allowed_on_event_comment(db): 

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

609 organizer, organizer_token = generate_user() 

610 _commenter, commenter_token = generate_user() 

611 

612 event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token) 

613 

614 with threads_session(commenter_token) as api: 

615 comment_thread_id = api.PostReply( 

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

617 ).thread_id 

618 assert comment_thread_id 

619 

620 

621def test_post_reply_incomplete_profile_blocked_on_discussion_thread(db): 

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

623 admin, admin_token = generate_user() 

624 _commenter, commenter_token = generate_user(complete_profile=False) 

625 

626 with session_scope() as session: 

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

628 

629 with discussions_session(admin_token) as api: 

630 discussion = api.CreateDiscussion( 

631 discussions_pb2.CreateDiscussionReq( 

632 title="A discussion", 

633 content="Content", 

634 owner_community_id=community_id, 

635 ) 

636 ) 

637 discussion_thread_id = discussion.thread.thread_id 

638 

639 with threads_session(commenter_token) as api: 

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

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

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

643 

644 

645def test_edit_comment_multiple_edits_creates_multiple_version_records(db): 

646 user, token = generate_user() 

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

648 comment_db_id = comment_thread_id // 10 

649 

650 with threads_session(token) as api: 

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

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

653 

654 with session_scope() as session: 

655 versions = ( 

656 session.execute( 

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

658 ) 

659 .scalars() 

660 .all() 

661 ) 

662 assert len(versions) == 2 

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

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

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

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