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
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-19 00:32 +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 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
29@pytest.fixture(autouse=True)
30def _(testconfig):
31 pass
34def test_threads_basic(db):
35 user1, token1 = generate_user()
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)
44 with threads_session(token1) as api:
45 bat_id = api.PostReply(threads_pb2.PostReplyReq(thread_id=PARENT_THREAD_ID, content="bat")).thread_id
47 cat_id = api.PostReply(threads_pb2.PostReplyReq(thread_id=PARENT_THREAD_ID, content="cat")).thread_id
49 dog_id = api.PostReply(threads_pb2.PostReplyReq(thread_id=PARENT_THREAD_ID, content="dog")).thread_id
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 ]
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
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
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
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]
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]
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."
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."
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."
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."
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."
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."
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))
135 # Get it with pagination
136 token = ""
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
143 assert token == ""
145 return ret.replies[0].thread_id # to be used as a test one level deeper
148def test_threads_pagination(db):
149 user1, token1 = generate_user()
151 PARENT_THREAD_ID = 10
153 # Create a dummy Thread (should be replaced by pages later on)
154 with session_scope() as session:
155 session.add(Thread())
157 with threads_session(token1) as api:
158 comment_id = pagination_test(api, PARENT_THREAD_ID)
159 pagination_test(api, comment_id)
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)
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
175 return parent_thread_id, comment_thread_id
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
184 with session_scope() as session:
185 comment = session.execute(select(Comment).where(Comment.id == comment_db_id)).scalar_one()
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
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
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)
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
211 with session_scope() as session:
212 reply = session.execute(select(Reply).where(Reply.id == reply_db_id)).scalar_one()
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
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()
227 parent_thread_id, _ = _make_thread_and_comment(author_token, content="secret")
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"
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
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()
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
256 with threads_session(author_token) as api:
257 api.PostReply(threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="my reply"))
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"
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
271def _approve(session, moderation_state_id):
272 session.execute(
273 select(ModerationState).where(ModerationState.id == moderation_state_id)
274 ).scalar_one().visibility = ModerationVisibility.visible
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()
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
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)
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
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()
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
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()
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)
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)
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
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()
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
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)
347 parent_thread_id, comment_thread_id = _make_thread_and_comment(author_token, content="approved comment")
348 comment_db_id = comment_thread_id // 10
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
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
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 )
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"
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)
382 parent_thread_id, comment_thread_id = _make_thread_and_comment(author_token, content="bad comment")
383 comment_db_id = comment_thread_id // 10
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
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
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 )
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
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
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)
424 parent_db_id, _ = divmod(parent_thread_id, 10)
426 with session_scope() as session:
427 assert total_num_responses(session, viewer_context, parent_db_id) == 0
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
438 with session_scope() as session:
439 assert total_num_responses(session, viewer_context, parent_db_id) == 1
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
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)
452 parent_db_id, _ = divmod(parent_thread_id, 10)
454 with session_scope() as session:
455 assert total_num_responses(session, author_context, parent_db_id) == 1
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
463 with threads_session(token) as api:
464 api.UpdateReply(threads_pb2.UpdateReplyReq(thread_id=comment_thread_id, content="edited comment"))
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
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
483 with threads_session(token) as api:
484 api.DeleteReply(threads_pb2.DeleteReplyReq(thread_id=comment_thread_id))
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
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")
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
508 with threads_session(token) as api:
509 api.UpdateReply(threads_pb2.UpdateReplyReq(thread_id=reply_thread_id, content="edited reply"))
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
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")
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
531 with threads_session(token) as api:
532 api.DeleteReply(threads_pb2.DeleteReplyReq(thread_id=reply_thread_id))
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
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)
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)
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)
574 event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)
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
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)
588 event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)
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
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
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()
606 event_thread_id = _create_event_and_get_thread_id(organizer, organizer_token)
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
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)
620 with session_scope() as session:
621 community_id = create_community(session, 0, 1, "Testing Community", [admin], [], None).id
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
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
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
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"))
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"