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

459 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 15:47 +0000

1import grpc 

2import pytest 

3from google.protobuf.wrappers_pb2 import StringValue 

4from sqlalchemy import select 

5 

6from couchers.db import session_scope 

7from couchers.models import ModerationObjectType, ModerationState, ModerationVisibility 

8from couchers.models.discussions import ContentChangeType, DiscussionVersion 

9from couchers.proto import admin_pb2, communities_pb2, discussions_pb2, moderation_pb2, notifications_pb2, threads_pb2 

10from couchers.utils import now, to_aware_datetime 

11from tests.fixtures.db import generate_user 

12from tests.fixtures.misc import Moderator, PushCollector, process_jobs 

13from tests.fixtures.sessions import ( 

14 communities_session, 

15 discussions_session, 

16 notifications_session, 

17 real_admin_session, 

18 real_moderation_session, 

19 threads_session, 

20) 

21from tests.test_communities import create_community, create_group 

22 

23 

24def test_create_discussion_errors(db): 

25 user, token = generate_user() 

26 with discussions_session(token) as api: 

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

28 api.CreateDiscussion( 

29 discussions_pb2.CreateDiscussionReq( 

30 title=None, 

31 content="dummy content", 

32 ) 

33 ) 

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

35 assert e.value.details() == "Missing discussion title." 

36 

37 with discussions_session(token) as api: 

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

39 api.CreateDiscussion( 

40 discussions_pb2.CreateDiscussionReq( 

41 title="dummy title", 

42 content=None, 

43 ) 

44 ) 

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

46 assert e.value.details() == "Missing discussion content." 

47 

48 

49def test_create_and_get_discussion(db, frozen_timewarp, push_collector: PushCollector, moderator: Moderator): 

50 generate_user() 

51 user, token = generate_user() 

52 user2, token2 = generate_user() 

53 generate_user() 

54 generate_user() 

55 

56 with notifications_session(token2) as notifications: 

57 notifications.SetNotificationSettings( 

58 notifications_pb2.SetNotificationSettingsReq( 

59 preferences=[ 

60 notifications_pb2.SingleNotificationPreference( 

61 topic="discussion", 

62 action="create", 

63 delivery_method="push", 

64 enabled=True, 

65 ) 

66 ], 

67 ) 

68 ) 

69 

70 with session_scope() as session: 

71 community = create_community(session, 0, 1, "Testing Community", [user2], [], None) 

72 group_id = create_group(session, "Testing Group", [user2], [], community).id 

73 community_id = community.id 

74 user2_id = user2.id 

75 

76 with discussions_session(token) as api: 

77 time_before_create = now() 

78 res = api.CreateDiscussion( 

79 discussions_pb2.CreateDiscussionReq( 

80 title="dummy title", 

81 content="dummy content", 

82 owner_community_id=community_id, 

83 ) 

84 ) 

85 time_after_create = now() 

86 

87 assert res.title == "dummy title" 

88 assert res.content == "dummy content" 

89 assert res.slug == "dummy-title" 

90 assert time_before_create <= to_aware_datetime(res.created) <= time_after_create 

91 assert res.creator_user_id == user.id 

92 assert res.owner_community_id == community_id 

93 

94 discussion_id = res.discussion_id 

95 

96 moderator.approve_discussion(discussion_id) 

97 process_jobs() 

98 

99 push = push_collector.pop_for_user(user2_id, last=True) 

100 assert push.content.title == "New discussion: dummy title" 

101 assert push.content.ios_title == "New Discussion" 

102 assert push.content.ios_subtitle == "dummy title" 

103 assert push.content.body == f"{user.name} started the discussion in Testing Community." 

104 

105 with discussions_session(token) as api: 

106 res = api.GetDiscussion( 

107 discussions_pb2.GetDiscussionReq( 

108 discussion_id=discussion_id, 

109 ) 

110 ) 

111 

112 assert res.title == "dummy title" 

113 assert res.content == "dummy content" 

114 assert res.slug == "dummy-title" 

115 assert time_before_create <= to_aware_datetime(res.created) <= time_after_create 

116 assert res.creator_user_id == user.id 

117 assert res.owner_community_id == community_id 

118 

119 with discussions_session(token) as api: 

120 time_before_create = now() 

121 res = api.CreateDiscussion( 

122 discussions_pb2.CreateDiscussionReq( 

123 title="dummy title", 

124 content="dummy content", 

125 owner_group_id=group_id, 

126 ) 

127 ) 

128 time_after_create = now() 

129 

130 assert res.title == "dummy title" 

131 assert res.content == "dummy content" 

132 assert res.slug == "dummy-title" 

133 assert time_before_create <= to_aware_datetime(res.created) <= time_after_create 

134 assert res.creator_user_id == user.id 

135 assert res.owner_group_id == group_id 

136 

137 discussion_id = res.discussion_id 

138 

139 with discussions_session(token) as api: 

140 res = api.GetDiscussion( 

141 discussions_pb2.GetDiscussionReq( 

142 discussion_id=discussion_id, 

143 ) 

144 ) 

145 

146 assert res.title == "dummy title" 

147 assert res.content == "dummy content" 

148 assert res.slug == "dummy-title" 

149 assert time_before_create <= to_aware_datetime(res.created) <= time_after_create 

150 assert res.creator_user_id == user.id 

151 assert res.owner_group_id == group_id 

152 

153 

154def test_update_discussion(db): 

155 user, token = generate_user() 

156 with session_scope() as session: 

157 community = create_community(session, 0, 1, "Testing Community", [user], [], None) 

158 community_id = community.id 

159 

160 with discussions_session(token) as api: 

161 res = api.CreateDiscussion( 

162 discussions_pb2.CreateDiscussionReq( 

163 title="Original title", 

164 content="Original content", 

165 owner_community_id=community_id, 

166 ) 

167 ) 

168 discussion_id = res.discussion_id 

169 assert res.can_edit 

170 

171 with discussions_session(token) as api: 

172 res = api.UpdateDiscussion( 

173 discussions_pb2.UpdateDiscussionReq( 

174 discussion_id=discussion_id, 

175 title=StringValue(value="Updated title"), 

176 content=StringValue(value="Updated content"), 

177 ) 

178 ) 

179 assert res.title == "Updated title" 

180 assert res.content == "Updated content" 

181 assert res.can_edit 

182 assert res.last_edited is not None 

183 

184 with discussions_session(token) as api: 

185 res = api.GetDiscussion(discussions_pb2.GetDiscussionReq(discussion_id=discussion_id)) 

186 assert res.title == "Updated title" 

187 assert res.content == "Updated content" 

188 assert res.last_edited is not None 

189 assert res.can_edit 

190 

191 

192def test_update_discussion_permission_denied(db): 

193 user, token = generate_user() 

194 other_user, other_token = generate_user() 

195 with session_scope() as session: 

196 community = create_community(session, 0, 1, "Testing Community", [user], [other_user], None) 

197 community_id = community.id 

198 

199 with discussions_session(token) as api: 

200 res = api.CreateDiscussion( 

201 discussions_pb2.CreateDiscussionReq( 

202 title="Original title", 

203 content="Original content", 

204 owner_community_id=community_id, 

205 ) 

206 ) 

207 discussion_id = res.discussion_id 

208 

209 with discussions_session(other_token) as api: 

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

211 api.UpdateDiscussion( 

212 discussions_pb2.UpdateDiscussionReq( 

213 discussion_id=discussion_id, 

214 ) 

215 ) 

216 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED 

217 assert e.value.details() == "You are not allowed to edit this discussion." 

218 

219 

220def test_update_deleted_discussion(db): 

221 user, token = generate_user() 

222 with session_scope() as session: 

223 community = create_community(session, 0, 1, "Testing Community", [user], [], None) 

224 community_id = community.id 

225 

226 with discussions_session(token) as api: 

227 res = api.CreateDiscussion( 

228 discussions_pb2.CreateDiscussionReq( 

229 title="Original title", 

230 content="Original content", 

231 owner_community_id=community_id, 

232 ) 

233 ) 

234 discussion_id = res.discussion_id 

235 api.DeleteDiscussion(discussions_pb2.DeleteDiscussionReq(discussion_id=discussion_id)) 

236 

237 with discussions_session(token) as api: 

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

239 api.UpdateDiscussion(discussions_pb2.UpdateDiscussionReq(discussion_id=discussion_id)) 

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

241 assert e.value.details() == "This discussion has been deleted." 

242 

243 

244def test_delete_discussion_by_creator(db): 

245 user, token = generate_user() 

246 with session_scope() as session: 

247 community = create_community(session, 0, 1, "Testing Community", [user], [], None) 

248 community_id = community.id 

249 

250 with discussions_session(token) as api: 

251 res = api.CreateDiscussion( 

252 discussions_pb2.CreateDiscussionReq( 

253 title="To be deleted", 

254 content="Some content", 

255 owner_community_id=community_id, 

256 ) 

257 ) 

258 discussion_id = res.discussion_id 

259 

260 api.DeleteDiscussion(discussions_pb2.DeleteDiscussionReq(discussion_id=discussion_id)) 

261 

262 res = api.GetDiscussion(discussions_pb2.GetDiscussionReq(discussion_id=discussion_id)) 

263 assert res.deleted 

264 assert res.title == "" 

265 assert res.content == "" 

266 assert res.creator_user_id == 0 

267 assert res.thread.thread_id != 0 

268 

269 

270def test_delete_discussion_permission_denied_for_non_creator(db): 

271 creator, creator_token = generate_user() 

272 community_admin, community_admin_token = generate_user() 

273 random_user, random_token = generate_user() 

274 with session_scope() as session: 

275 community = create_community( 

276 session, 0, 1, "Testing Community", [community_admin], [creator, random_user], None 

277 ) 

278 community_id = community.id 

279 

280 with discussions_session(creator_token) as api: 

281 res = api.CreateDiscussion( 

282 discussions_pb2.CreateDiscussionReq( 

283 title="Only creator can delete", 

284 content="Some content", 

285 owner_community_id=community_id, 

286 ) 

287 ) 

288 discussion_id = res.discussion_id 

289 assert res.can_edit 

290 

291 with discussions_session(random_token) as api: 

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

293 api.DeleteDiscussion(discussions_pb2.DeleteDiscussionReq(discussion_id=discussion_id)) 

294 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED 

295 

296 with discussions_session(community_admin_token) as api: 

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

298 api.DeleteDiscussion(discussions_pb2.DeleteDiscussionReq(discussion_id=discussion_id)) 

299 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED 

300 

301 

302def test_deleted_discussion_not_in_list(db): 

303 user, token = generate_user() 

304 with session_scope() as session: 

305 community = create_community(session, 0, 1, "Testing Community", [user], [], None) 

306 community_id = community.id 

307 

308 with discussions_session(token) as api: 

309 res = api.CreateDiscussion( 

310 discussions_pb2.CreateDiscussionReq( 

311 title="Visible discussion", 

312 content="Content", 

313 owner_community_id=community_id, 

314 ) 

315 ) 

316 visible_id = res.discussion_id 

317 

318 res = api.CreateDiscussion( 

319 discussions_pb2.CreateDiscussionReq( 

320 title="Deleted discussion", 

321 content="Content", 

322 owner_community_id=community_id, 

323 ) 

324 ) 

325 deleted_id = res.discussion_id 

326 api.DeleteDiscussion(discussions_pb2.DeleteDiscussionReq(discussion_id=deleted_id)) 

327 

328 with communities_session(token) as api: 

329 res = api.ListDiscussions(communities_pb2.ListDiscussionsReq(community_id=community_id)) 

330 ids = [d.discussion_id for d in res.discussions] 

331 assert visible_id in ids 

332 assert deleted_id not in ids 

333 

334 

335def test_deleted_discussion_thread_still_accessible(db, moderator: Moderator): 

336 user, token = generate_user() 

337 commenter, commenter_token = generate_user() 

338 with session_scope() as session: 

339 community = create_community(session, 0, 1, "Testing Community", [user], [commenter], None) 

340 community_id = community.id 

341 

342 with discussions_session(token) as api: 

343 res = api.CreateDiscussion( 

344 discussions_pb2.CreateDiscussionReq( 

345 title="Discussion with comments", 

346 content="Content", 

347 owner_community_id=community_id, 

348 ) 

349 ) 

350 discussion_id = res.discussion_id 

351 thread_id = res.thread.thread_id 

352 

353 process_jobs() 

354 

355 with threads_session(commenter_token) as api: 

356 comment_thread_id = api.PostReply(threads_pb2.PostReplyReq(thread_id=thread_id, content="a comment")).thread_id 

357 moderator.approve_thread_post(comment_thread_id) 

358 

359 with discussions_session(token) as api: 

360 api.DeleteDiscussion(discussions_pb2.DeleteDiscussionReq(discussion_id=discussion_id)) 

361 res = api.GetDiscussion(discussions_pb2.GetDiscussionReq(discussion_id=discussion_id)) 

362 assert res.deleted 

363 assert res.thread.num_responses == 1 

364 

365 with threads_session(token) as api: 

366 res = api.GetThread(threads_pb2.GetThreadReq(thread_id=thread_id)) 

367 assert len(res.replies) == 1 

368 

369 

370def test_delete_comment_shows_placeholder_with_replies(db, moderator: Moderator): 

371 """Deleting a top-level comment that has nested replies should show a deleted 

372 placeholder so the replies remain visible.""" 

373 user, token = generate_user() 

374 commenter, commenter_token = generate_user() 

375 replier, replier_token = generate_user() 

376 with session_scope() as session: 

377 community = create_community(session, 0, 1, "Testing Community", [user], [commenter, replier], None) 

378 community_id = community.id 

379 

380 with discussions_session(token) as api: 

381 discussion = api.CreateDiscussion( 

382 discussions_pb2.CreateDiscussionReq( 

383 title="A discussion", 

384 content="Content", 

385 owner_community_id=community_id, 

386 ) 

387 ) 

388 discussion_id = discussion.discussion_id 

389 thread_id = discussion.thread.thread_id 

390 

391 # commenter adds a top-level comment 

392 with threads_session(commenter_token) as api: 

393 comment_thread_id = api.PostReply( 

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

395 ).thread_id 

396 

397 # replier adds two nested replies 

398 with threads_session(replier_token) as api: 

399 reply_thread_id_1 = api.PostReply( 

400 threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="nested reply 1") 

401 ).thread_id 

402 reply_thread_id_2 = api.PostReply( 

403 threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="nested reply 2") 

404 ).thread_id 

405 

406 moderator.approve_thread_post(comment_thread_id) 

407 moderator.approve_thread_post(reply_thread_id_1) 

408 moderator.approve_thread_post(reply_thread_id_2) 

409 

410 # commenter deletes their top-level comment 

411 with threads_session(commenter_token) as api: 

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

413 

414 # the top-level comment appears as a deleted placeholder (has replies), replies still visible 

415 with threads_session(token) as api: 

416 thread_res = api.GetThread(threads_pb2.GetThreadReq(thread_id=thread_id)) 

417 assert len(thread_res.replies) == 1 

418 placeholder = thread_res.replies[0] 

419 assert placeholder.deleted 

420 assert placeholder.content == "" 

421 assert placeholder.author_user_id == 0 

422 

423 nested = api.GetThread(threads_pb2.GetThreadReq(thread_id=placeholder.thread_id)) 

424 assert len(nested.replies) == 2 

425 

426 # num_responses counts the 2 visible nested replies (not the deleted top-level) 

427 with discussions_session(token) as api: 

428 disc = api.GetDiscussion(discussions_pb2.GetDiscussionReq(discussion_id=discussion_id)) 

429 assert disc.thread.num_responses == 2 

430 

431 

432def test_admin_delete_discussion(db): 

433 user, token = generate_user() 

434 admin, admin_token = generate_user(is_superuser=True) 

435 with session_scope() as session: 

436 community = create_community(session, 0, 1, "Testing Community", [user], [], None) 

437 community_id = community.id 

438 

439 with discussions_session(token) as api: 

440 res = api.CreateDiscussion( 

441 discussions_pb2.CreateDiscussionReq( 

442 title="Admin will delete this", 

443 content="Content", 

444 owner_community_id=community_id, 

445 ) 

446 ) 

447 discussion_id = res.discussion_id 

448 

449 with real_admin_session(admin_token) as api: 

450 api.DeleteDiscussion(admin_pb2.AdminDeleteDiscussionReq(discussion_id=discussion_id)) 

451 

452 with discussions_session(token) as api: 

453 res = api.GetDiscussion(discussions_pb2.GetDiscussionReq(discussion_id=discussion_id)) 

454 assert res.deleted 

455 

456 

457def test_discussion_notifications_regression(db, push_collector: PushCollector, moderator: Moderator): 

458 generate_user() 

459 user, token = generate_user() 

460 user2, token2 = generate_user() 

461 user3, token3 = generate_user() 

462 generate_user() 

463 generate_user() 

464 

465 with session_scope() as session: 

466 community = create_community(session, 0, 1, "Testing Community", [user2], [], None) 

467 group_id = create_group(session, "Testing Group", [user2], [], community).id 

468 community_id = community.id 

469 user2_id = user2.id 

470 

471 with discussions_session(token) as api: 

472 res = api.CreateDiscussion( 

473 discussions_pb2.CreateDiscussionReq( 

474 title="dummy title", 

475 content="dummy content", 

476 owner_community_id=community_id, 

477 ) 

478 ) 

479 

480 assert res.title == "dummy title" 

481 assert res.content == "dummy content" 

482 assert res.slug == "dummy-title" 

483 assert res.creator_user_id == user.id 

484 assert res.owner_community_id == community_id 

485 

486 discussion_id = res.discussion_id 

487 thread_id = res.thread.thread_id 

488 

489 with threads_session(token2) as api: 

490 comment_thread_id = api.PostReply(threads_pb2.PostReplyReq(thread_id=thread_id, content="comment")).thread_id 

491 moderator.approve_thread_post(comment_thread_id) 

492 

493 with threads_session(token3) as api: 

494 reply_thread_id_a = api.PostReply( 

495 threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="reply to comment") 

496 ).thread_id 

497 moderator.approve_thread_post(reply_thread_id_a) 

498 

499 with threads_session(token) as api: 

500 reply_thread_id_b = api.PostReply( 

501 threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="reply to reply to comment") 

502 ).thread_id 

503 moderator.approve_thread_post(reply_thread_id_b) 

504 

505 process_jobs() 

506 

507 # User2 should get 2 notifications about 2 replies to their comment, User3 should get 1 notification about 1 reply 

508 push = push_collector.pop_for_user(user2_id, last=False) 

509 assert push.content.title == f"{user3.name} • dummy title" 

510 assert push.topic_action == "thread:reply" 

511 

512 push = push_collector.pop_for_user(user2_id, last=True) 

513 assert push.content.title == f"{user.name} • dummy title" 

514 assert push.topic_action == "thread:reply" 

515 

516 push = push_collector.pop_for_user(user3.id, last=True) 

517 assert push.content.title == f"{user.name} • dummy title" 

518 assert push.topic_action == "thread:reply" 

519 

520 

521def test_create_discussion_creates_moderation_state(db): 

522 user, token = generate_user() 

523 

524 with session_scope() as session: 

525 community = create_community(session, 0, 1, "Testing Community", [user], [], None) 

526 community_id = community.id 

527 

528 with discussions_session(token) as api: 

529 res = api.CreateDiscussion( 

530 discussions_pb2.CreateDiscussionReq( 

531 title="t", 

532 content="c", 

533 owner_community_id=community_id, 

534 ) 

535 ) 

536 discussion_id = res.discussion_id 

537 

538 with session_scope() as session: 

539 state = session.execute( 

540 select(ModerationState) 

541 .where(ModerationState.object_type == ModerationObjectType.discussion) 

542 .where(ModerationState.object_id == discussion_id) 

543 ).scalar_one() 

544 assert state.visibility == ModerationVisibility.shadowed 

545 

546 

547def test_shadowed_discussion_visible_to_author_only(db): 

548 author, author_token = generate_user() 

549 _, other_token = generate_user() 

550 

551 with session_scope() as session: 

552 community = create_community(session, 0, 1, "Testing Community", [author], [], None) 

553 community_id = community.id 

554 

555 with discussions_session(author_token) as api: 

556 res = api.CreateDiscussion( 

557 discussions_pb2.CreateDiscussionReq( 

558 title="secret", 

559 content="secret content", 

560 owner_community_id=community_id, 

561 ) 

562 ) 

563 discussion_id = res.discussion_id 

564 

565 # Author can read their own shadowed discussion 

566 with discussions_session(author_token) as api: 

567 res = api.GetDiscussion(discussions_pb2.GetDiscussionReq(discussion_id=discussion_id)) 

568 assert res.title == "secret" 

569 

570 # Other user cannot 

571 with discussions_session(other_token) as api: 

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

573 api.GetDiscussion(discussions_pb2.GetDiscussionReq(discussion_id=discussion_id)) 

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

575 

576 

577def test_shadowed_discussion_excluded_from_listings(db): 

578 author, author_token = generate_user() 

579 other, other_token = generate_user() 

580 

581 with session_scope() as session: 

582 community = create_community(session, 0, 1, "Testing Community", [author, other], [], None) 

583 community_id = community.id 

584 

585 with discussions_session(author_token) as api: 

586 api.CreateDiscussion( 

587 discussions_pb2.CreateDiscussionReq( 

588 title="hidden-from-listings", 

589 content="c", 

590 owner_community_id=community_id, 

591 ) 

592 ) 

593 

594 # Other user does not see the shadowed discussion in listings 

595 with communities_session(other_token) as api: 

596 res = api.ListDiscussions(communities_pb2.ListDiscussionsReq(community_id=community_id)) 

597 assert [d.title for d in res.discussions] == [] 

598 

599 

600def test_approved_discussion_visible_to_others(db, moderator: Moderator): 

601 author, author_token = generate_user() 

602 _, other_token = generate_user() 

603 

604 with session_scope() as session: 

605 community = create_community(session, 0, 1, "Testing Community", [author], [], None) 

606 community_id = community.id 

607 

608 with discussions_session(author_token) as api: 

609 res = api.CreateDiscussion( 

610 discussions_pb2.CreateDiscussionReq( 

611 title="hello", 

612 content="world", 

613 owner_community_id=community_id, 

614 ) 

615 ) 

616 discussion_id = res.discussion_id 

617 

618 moderator.approve_discussion(discussion_id) 

619 

620 with discussions_session(other_token) as api: 

621 res = api.GetDiscussion(discussions_pb2.GetDiscussionReq(discussion_id=discussion_id)) 

622 assert res.title == "hello" 

623 

624 

625def test_hidden_discussion_filtered_for_author_too(db, moderator: Moderator): 

626 """Once admin hides a discussion, even the author cannot read it.""" 

627 author, author_token = generate_user() 

628 

629 with session_scope() as session: 

630 community = create_community(session, 0, 1, "Testing Community", [author], [], None) 

631 community_id = community.id 

632 

633 with discussions_session(author_token) as api: 

634 res = api.CreateDiscussion( 

635 discussions_pb2.CreateDiscussionReq( 

636 title="bad", 

637 content="c", 

638 owner_community_id=community_id, 

639 ) 

640 ) 

641 discussion_id = res.discussion_id 

642 

643 # Hide via moderator (visibility=hidden, not visible) 

644 with real_moderation_session(moderator.token) as api: 

645 state_res = api.GetModerationState( 

646 moderation_pb2.GetModerationStateReq( 

647 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_DISCUSSION, 

648 object_id=discussion_id, 

649 ) 

650 ) 

651 api.ModerateContent( 

652 moderation_pb2.ModerateContentReq( 

653 moderation_state_id=state_res.moderation_state.moderation_state_id, 

654 action=moderation_pb2.MODERATION_ACTION_HIDE, 

655 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

656 reason="bad content", 

657 ) 

658 ) 

659 

660 with discussions_session(author_token) as api: 

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

662 api.GetDiscussion(discussions_pb2.GetDiscussionReq(discussion_id=discussion_id)) 

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

664 

665 

666def test_list_my_communities_discussions_respects_moderation(db, moderator: Moderator): 

667 """A shadowed discussion is hidden from other community members until approved.""" 

668 author, author_token = generate_user() 

669 other, other_token = generate_user() 

670 

671 with session_scope() as session: 

672 community = create_community(session, 0, 1, "Testing Community", [author, other], [], None) 

673 community_id = community.id 

674 

675 with discussions_session(author_token) as api: 

676 discussion_id = api.CreateDiscussion( 

677 discussions_pb2.CreateDiscussionReq( 

678 title="hello", 

679 content="world", 

680 owner_community_id=community_id, 

681 ) 

682 ).discussion_id 

683 

684 # Author sees their own shadowed discussion 

685 with discussions_session(author_token) as api: 

686 res = api.ListMyCommunitiesDiscussions(discussions_pb2.ListMyCommunitiesDiscussionsReq()) 

687 assert [d.title for d in res.discussions] == ["hello"] 

688 

689 # Other members do not see the shadowed discussion 

690 with discussions_session(other_token) as api: 

691 res = api.ListMyCommunitiesDiscussions(discussions_pb2.ListMyCommunitiesDiscussionsReq()) 

692 assert [d.title for d in res.discussions] == [] 

693 

694 moderator.approve_discussion(discussion_id) 

695 

696 # Once approved, it shows up for other members too 

697 with discussions_session(other_token) as api: 

698 res = api.ListMyCommunitiesDiscussions(discussions_pb2.ListMyCommunitiesDiscussionsReq()) 

699 assert [d.title for d in res.discussions] == ["hello"] 

700 

701 

702def test_update_discussion_creates_version_record(db): 

703 user, token = generate_user() 

704 with session_scope() as session: 

705 community = create_community(session, 0, 1, "Testing Community", [user], [], None) 

706 community_id = community.id 

707 

708 with discussions_session(token) as api: 

709 res = api.CreateDiscussion( 

710 discussions_pb2.CreateDiscussionReq( 

711 title="Original title", 

712 content="Original content", 

713 owner_community_id=community_id, 

714 ) 

715 ) 

716 discussion_id = res.discussion_id 

717 

718 with discussions_session(token) as api: 

719 api.UpdateDiscussion( 

720 discussions_pb2.UpdateDiscussionReq( 

721 discussion_id=discussion_id, 

722 title=StringValue(value="Updated title"), 

723 content=StringValue(value="Updated content"), 

724 ) 

725 ) 

726 

727 with session_scope() as session: 

728 versions = ( 

729 session.execute(select(DiscussionVersion).where(DiscussionVersion.discussion_id == discussion_id)) 

730 .scalars() 

731 .all() 

732 ) 

733 assert len(versions) == 1 

734 v = versions[0] 

735 assert v.change_type == ContentChangeType.edit 

736 assert v.old_title == "Original title" 

737 assert v.new_title == "Updated title" 

738 assert v.old_content == "Original content" 

739 assert v.new_content == "Updated content" 

740 assert v.editor_user_id == user.id 

741 

742 

743def test_update_discussion_multiple_edits_creates_multiple_version_records(db): 

744 user, token = generate_user() 

745 with session_scope() as session: 

746 community = create_community(session, 0, 1, "Testing Community", [user], [], None) 

747 community_id = community.id 

748 

749 with discussions_session(token) as api: 

750 discussion_id = api.CreateDiscussion( 

751 discussions_pb2.CreateDiscussionReq( 

752 title="v1 title", 

753 content="v1 content", 

754 owner_community_id=community_id, 

755 ) 

756 ).discussion_id 

757 

758 with discussions_session(token) as api: 

759 api.UpdateDiscussion( 

760 discussions_pb2.UpdateDiscussionReq( 

761 discussion_id=discussion_id, 

762 title=StringValue(value="v2 title"), 

763 content=StringValue(value="v2 content"), 

764 ) 

765 ) 

766 

767 with discussions_session(token) as api: 

768 api.UpdateDiscussion( 

769 discussions_pb2.UpdateDiscussionReq( 

770 discussion_id=discussion_id, 

771 title=StringValue(value="v3 title"), 

772 content=StringValue(value="v3 content"), 

773 ) 

774 ) 

775 

776 with session_scope() as session: 

777 versions = ( 

778 session.execute( 

779 select(DiscussionVersion) 

780 .where(DiscussionVersion.discussion_id == discussion_id) 

781 .order_by(DiscussionVersion.id) 

782 ) 

783 .scalars() 

784 .all() 

785 ) 

786 assert len(versions) == 2 

787 assert versions[0].old_title == "v1 title" 

788 assert versions[0].new_title == "v2 title" 

789 assert versions[1].old_title == "v2 title" 

790 assert versions[1].new_title == "v3 title" 

791 

792 

793def test_delete_discussion_creates_version_record(db): 

794 user, token = generate_user() 

795 with session_scope() as session: 

796 community = create_community(session, 0, 1, "Testing Community", [user], [], None) 

797 community_id = community.id 

798 

799 with discussions_session(token) as api: 

800 discussion_id = api.CreateDiscussion( 

801 discussions_pb2.CreateDiscussionReq( 

802 title="To be deleted", 

803 content="Some content", 

804 owner_community_id=community_id, 

805 ) 

806 ).discussion_id 

807 api.DeleteDiscussion(discussions_pb2.DeleteDiscussionReq(discussion_id=discussion_id)) 

808 

809 with session_scope() as session: 

810 versions = ( 

811 session.execute(select(DiscussionVersion).where(DiscussionVersion.discussion_id == discussion_id)) 

812 .scalars() 

813 .all() 

814 ) 

815 assert len(versions) == 1 

816 v = versions[0] 

817 assert v.change_type == ContentChangeType.delete 

818 assert v.old_title == "To be deleted" 

819 assert v.new_title is None 

820 assert v.old_content == "Some content" 

821 assert v.new_content is None 

822 assert v.editor_user_id == user.id 

823 

824 

825def test_admin_delete_discussion_creates_version_record(db): 

826 user, token = generate_user() 

827 admin, admin_token = generate_user(is_superuser=True) 

828 with session_scope() as session: 

829 community = create_community(session, 0, 1, "Testing Community", [user], [], None) 

830 community_id = community.id 

831 

832 with discussions_session(token) as api: 

833 discussion_id = api.CreateDiscussion( 

834 discussions_pb2.CreateDiscussionReq( 

835 title="Admin will delete this", 

836 content="Some content", 

837 owner_community_id=community_id, 

838 ) 

839 ).discussion_id 

840 

841 with real_admin_session(admin_token) as api: 

842 api.DeleteDiscussion(admin_pb2.AdminDeleteDiscussionReq(discussion_id=discussion_id)) 

843 

844 with session_scope() as session: 

845 versions = ( 

846 session.execute(select(DiscussionVersion).where(DiscussionVersion.discussion_id == discussion_id)) 

847 .scalars() 

848 .all() 

849 ) 

850 assert len(versions) == 1 

851 v = versions[0] 

852 assert v.change_type == ContentChangeType.delete 

853 assert v.old_title == "Admin will delete this" 

854 assert v.new_title is None 

855 assert v.editor_user_id == admin.id 

856 

857 

858def test_create_discussion_incomplete_profile(db): 

859 user, token = generate_user(complete_profile=False) 

860 user2, token2 = generate_user() 

861 

862 with session_scope() as session: 

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

864 

865 with discussions_session(token) as api: 

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

867 api.CreateDiscussion( 

868 discussions_pb2.CreateDiscussionReq( 

869 title="dummy title", 

870 content="dummy content", 

871 owner_community_id=community_id, 

872 ) 

873 ) 

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

875 assert e.value.details() == "You have to complete your profile before you can start a discussion."