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
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
1import string
2import textwrap
3from datetime import timedelta
5import grpc
6import pytest
7from sqlalchemy import select
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
30def test_threads_basic(db):
31 user1, token1 = generate_user()
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)
40 with threads_session(token1) as api:
41 bat_id = api.PostReply(threads_pb2.PostReplyReq(thread_id=PARENT_THREAD_ID, content="bat")).thread_id
43 cat_id = api.PostReply(threads_pb2.PostReplyReq(thread_id=PARENT_THREAD_ID, content="cat")).thread_id
45 dog_id = api.PostReply(threads_pb2.PostReplyReq(thread_id=PARENT_THREAD_ID, content="dog")).thread_id
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 ]
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
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
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
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]
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]
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."
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."
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."
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."
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."
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."
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))
131 # Get it with pagination
132 token = ""
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
139 assert token == ""
141 return ret.replies[0].thread_id # to be used as a test one level deeper
144def test_threads_pagination(db):
145 user1, token1 = generate_user()
147 PARENT_THREAD_ID = 10
149 # Create a dummy Thread (should be replaced by pages later on)
150 with session_scope() as session:
151 session.add(Thread())
153 with threads_session(token1) as api:
154 comment_id = pagination_test(api, PARENT_THREAD_ID)
155 pagination_test(api, comment_id)
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)
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
171 return parent_thread_id, comment_thread_id
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
180 with session_scope() as session:
181 comment = session.execute(select(Comment).where(Comment.id == comment_db_id)).scalar_one()
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
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
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)
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
207 with session_scope() as session:
208 reply = session.execute(select(Reply).where(Reply.id == reply_db_id)).scalar_one()
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
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()
223 parent_thread_id, _ = _make_thread_and_comment(author_token, content="secret")
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"
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
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()
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)
246 with threads_session(author_token) as api:
247 api.PostReply(threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="my reply"))
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"
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
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()
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)
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
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()
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
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()
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)
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)
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
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()
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
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)
321 parent_thread_id, comment_thread_id = _make_thread_and_comment(author_token, content="approved comment")
322 comment_db_id = comment_thread_id // 10
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
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
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 )
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"
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()
355 parent_thread_id, comment_thread_id = _make_thread_and_comment(author_token, content="bad comment")
356 moderator.approve_thread_post(comment_thread_id)
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
362 moderator.set_thread_post_visibility(comment_thread_id, moderation_pb2.MODERATION_VISIBILITY_HIDDEN)
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
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
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)
381 parent_db_id, _ = divmod(parent_thread_id, 10)
383 with session_scope() as session:
384 assert total_num_responses(session, viewer_context, parent_db_id) == 0
386 moderator.approve_thread_post(comment_thread_id)
388 with session_scope() as session:
389 assert total_num_responses(session, viewer_context, parent_db_id) == 1
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
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)
402 parent_db_id, _ = divmod(parent_thread_id, 10)
404 with session_scope() as session:
405 assert total_num_responses(session, author_context, parent_db_id) == 1
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
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)
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
424 moderator.approve_thread_post(comment_thread_id)
425 moderator.approve_thread_post(reply_thread_id)
427 with session_scope() as session:
428 assert total_num_responses(session, viewer_context, parent_db_id) == 2
430 moderator.set_thread_post_visibility(comment_thread_id, moderation_pb2.MODERATION_VISIBILITY_HIDDEN)
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
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
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)
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
454 moderator.approve_thread_post(reply_thread_id)
456 with session_scope() as session:
457 assert total_num_responses(session, replier_context, parent_db_id) == 0
459 moderator.approve_thread_post(comment_thread_id)
461 with session_scope() as session:
462 assert total_num_responses(session, replier_context, parent_db_id) == 2
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
470 with threads_session(token) as api:
471 api.UpdateReply(threads_pb2.UpdateReplyReq(thread_id=comment_thread_id, content="edited comment"))
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
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
490 with threads_session(token) as api:
491 api.DeleteReply(threads_pb2.DeleteReplyReq(thread_id=comment_thread_id))
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
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")
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
515 with threads_session(token) as api:
516 api.UpdateReply(threads_pb2.UpdateReplyReq(thread_id=reply_thread_id, content="edited reply"))
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
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")
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
538 with threads_session(token) as api:
539 api.DeleteReply(threads_pb2.DeleteReplyReq(thread_id=reply_thread_id))
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
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)
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)
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)
580 event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)
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
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)
594 event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)
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
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
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()
612 event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)
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
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)
626 with session_scope() as session:
627 community_id = create_community(session, 0, 1, "Testing Community", [admin], [], None).id
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
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
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
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"))
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"