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

455 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +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 

24@pytest.fixture(autouse=True) 

25def _(testconfig): 

26 pass 

27 

28 

29def test_create_discussion_errors(db): 

30 user, token = generate_user() 

31 with discussions_session(token) as api: 

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

33 api.CreateDiscussion( 

34 discussions_pb2.CreateDiscussionReq( 

35 title=None, 

36 content="dummy content", 

37 ) 

38 ) 

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

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

41 

42 with discussions_session(token) as api: 

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

44 api.CreateDiscussion( 

45 discussions_pb2.CreateDiscussionReq( 

46 title="dummy title", 

47 content=None, 

48 ) 

49 ) 

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

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

52 

53 

54def test_create_and_get_discussion(db, push_collector: PushCollector, moderator: Moderator): 

55 generate_user() 

56 user, token = generate_user() 

57 user2, token2 = generate_user() 

58 generate_user() 

59 generate_user() 

60 

61 with notifications_session(token2) as notifications: 

62 notifications.SetNotificationSettings( 

63 notifications_pb2.SetNotificationSettingsReq( 

64 preferences=[ 

65 notifications_pb2.SingleNotificationPreference( 

66 topic="discussion", 

67 action="create", 

68 delivery_method="push", 

69 enabled=True, 

70 ) 

71 ], 

72 ) 

73 ) 

74 

75 with session_scope() as session: 

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

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

78 community_id = community.id 

79 user2_id = user2.id 

80 

81 with discussions_session(token) as api: 

82 time_before_create = now() 

83 res = api.CreateDiscussion( 

84 discussions_pb2.CreateDiscussionReq( 

85 title="dummy title", 

86 content="dummy content", 

87 owner_community_id=community_id, 

88 ) 

89 ) 

90 time_after_create = now() 

91 

92 assert res.title == "dummy title" 

93 assert res.content == "dummy content" 

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

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

96 assert res.creator_user_id == user.id 

97 assert res.owner_community_id == community_id 

98 

99 discussion_id = res.discussion_id 

100 

101 moderator.approve_discussion(discussion_id) 

102 process_jobs() 

103 

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

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

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

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

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

109 

110 with discussions_session(token) as api: 

111 res = api.GetDiscussion( 

112 discussions_pb2.GetDiscussionReq( 

113 discussion_id=discussion_id, 

114 ) 

115 ) 

116 

117 assert res.title == "dummy title" 

118 assert res.content == "dummy content" 

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

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

121 assert res.creator_user_id == user.id 

122 assert res.owner_community_id == community_id 

123 

124 with discussions_session(token) as api: 

125 time_before_create = now() 

126 res = api.CreateDiscussion( 

127 discussions_pb2.CreateDiscussionReq( 

128 title="dummy title", 

129 content="dummy content", 

130 owner_group_id=group_id, 

131 ) 

132 ) 

133 time_after_create = now() 

134 

135 assert res.title == "dummy title" 

136 assert res.content == "dummy content" 

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

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

139 assert res.creator_user_id == user.id 

140 assert res.owner_group_id == group_id 

141 

142 discussion_id = res.discussion_id 

143 

144 with discussions_session(token) as api: 

145 res = api.GetDiscussion( 

146 discussions_pb2.GetDiscussionReq( 

147 discussion_id=discussion_id, 

148 ) 

149 ) 

150 

151 assert res.title == "dummy title" 

152 assert res.content == "dummy content" 

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

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

155 assert res.creator_user_id == user.id 

156 assert res.owner_group_id == group_id 

157 

158 

159def test_update_discussion(db): 

160 user, token = generate_user() 

161 with session_scope() as session: 

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

163 community_id = community.id 

164 

165 with discussions_session(token) as api: 

166 res = api.CreateDiscussion( 

167 discussions_pb2.CreateDiscussionReq( 

168 title="Original title", 

169 content="Original content", 

170 owner_community_id=community_id, 

171 ) 

172 ) 

173 discussion_id = res.discussion_id 

174 assert res.can_edit 

175 

176 with discussions_session(token) as api: 

177 res = api.UpdateDiscussion( 

178 discussions_pb2.UpdateDiscussionReq( 

179 discussion_id=discussion_id, 

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

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

182 ) 

183 ) 

184 assert res.title == "Updated title" 

185 assert res.content == "Updated content" 

186 assert res.can_edit 

187 assert res.last_edited is not None 

188 

189 with discussions_session(token) as api: 

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

191 assert res.title == "Updated title" 

192 assert res.content == "Updated content" 

193 assert res.last_edited is not None 

194 assert res.can_edit 

195 

196 

197def test_update_discussion_permission_denied(db): 

198 user, token = generate_user() 

199 other_user, other_token = generate_user() 

200 with session_scope() as session: 

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

202 community_id = community.id 

203 

204 with discussions_session(token) as api: 

205 res = api.CreateDiscussion( 

206 discussions_pb2.CreateDiscussionReq( 

207 title="Original title", 

208 content="Original content", 

209 owner_community_id=community_id, 

210 ) 

211 ) 

212 discussion_id = res.discussion_id 

213 

214 with discussions_session(other_token) as api: 

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

216 api.UpdateDiscussion( 

217 discussions_pb2.UpdateDiscussionReq( 

218 discussion_id=discussion_id, 

219 ) 

220 ) 

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

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

223 

224 

225def test_update_deleted_discussion(db): 

226 user, token = generate_user() 

227 with session_scope() as session: 

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

229 community_id = community.id 

230 

231 with discussions_session(token) as api: 

232 res = api.CreateDiscussion( 

233 discussions_pb2.CreateDiscussionReq( 

234 title="Original title", 

235 content="Original content", 

236 owner_community_id=community_id, 

237 ) 

238 ) 

239 discussion_id = res.discussion_id 

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

241 

242 with discussions_session(token) as api: 

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

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

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

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

247 

248 

249def test_delete_discussion_by_creator(db): 

250 user, token = generate_user() 

251 with session_scope() as session: 

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

253 community_id = community.id 

254 

255 with discussions_session(token) as api: 

256 res = api.CreateDiscussion( 

257 discussions_pb2.CreateDiscussionReq( 

258 title="To be deleted", 

259 content="Some content", 

260 owner_community_id=community_id, 

261 ) 

262 ) 

263 discussion_id = res.discussion_id 

264 

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

266 

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

268 assert res.deleted 

269 assert res.title == "" 

270 assert res.content == "" 

271 assert res.creator_user_id == 0 

272 assert res.thread.thread_id != 0 

273 

274 

275def test_delete_discussion_permission_denied_for_non_creator(db): 

276 creator, creator_token = generate_user() 

277 community_admin, community_admin_token = generate_user() 

278 random_user, random_token = generate_user() 

279 with session_scope() as session: 

280 community = create_community( 

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

282 ) 

283 community_id = community.id 

284 

285 with discussions_session(creator_token) as api: 

286 res = api.CreateDiscussion( 

287 discussions_pb2.CreateDiscussionReq( 

288 title="Only creator can delete", 

289 content="Some content", 

290 owner_community_id=community_id, 

291 ) 

292 ) 

293 discussion_id = res.discussion_id 

294 assert res.can_edit 

295 

296 with discussions_session(random_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 with discussions_session(community_admin_token) as api: 

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

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

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

305 

306 

307def test_deleted_discussion_not_in_list(db): 

308 user, token = generate_user() 

309 with session_scope() as session: 

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

311 community_id = community.id 

312 

313 with discussions_session(token) as api: 

314 res = api.CreateDiscussion( 

315 discussions_pb2.CreateDiscussionReq( 

316 title="Visible discussion", 

317 content="Content", 

318 owner_community_id=community_id, 

319 ) 

320 ) 

321 visible_id = res.discussion_id 

322 

323 res = api.CreateDiscussion( 

324 discussions_pb2.CreateDiscussionReq( 

325 title="Deleted discussion", 

326 content="Content", 

327 owner_community_id=community_id, 

328 ) 

329 ) 

330 deleted_id = res.discussion_id 

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

332 

333 with communities_session(token) as api: 

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

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

336 assert visible_id in ids 

337 assert deleted_id not in ids 

338 

339 

340def test_deleted_discussion_thread_still_accessible(db, moderator: Moderator): 

341 user, token = generate_user() 

342 commenter, commenter_token = generate_user() 

343 with session_scope() as session: 

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

345 community_id = community.id 

346 

347 with discussions_session(token) as api: 

348 res = api.CreateDiscussion( 

349 discussions_pb2.CreateDiscussionReq( 

350 title="Discussion with comments", 

351 content="Content", 

352 owner_community_id=community_id, 

353 ) 

354 ) 

355 discussion_id = res.discussion_id 

356 thread_id = res.thread.thread_id 

357 

358 process_jobs() 

359 

360 with threads_session(commenter_token) as api: 

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

362 moderator.approve_thread_post(comment_thread_id) 

363 

364 with discussions_session(token) as api: 

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

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

367 assert res.deleted 

368 assert res.thread.num_responses == 1 

369 

370 with threads_session(token) as api: 

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

372 assert len(res.replies) == 1 

373 

374 

375def test_delete_comment_shows_placeholder_with_replies(db, moderator: Moderator): 

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

377 placeholder so the replies remain visible.""" 

378 user, token = generate_user() 

379 commenter, commenter_token = generate_user() 

380 replier, replier_token = generate_user() 

381 with session_scope() as session: 

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

383 community_id = community.id 

384 

385 with discussions_session(token) as api: 

386 discussion = api.CreateDiscussion( 

387 discussions_pb2.CreateDiscussionReq( 

388 title="A discussion", 

389 content="Content", 

390 owner_community_id=community_id, 

391 ) 

392 ) 

393 discussion_id = discussion.discussion_id 

394 thread_id = discussion.thread.thread_id 

395 

396 # commenter adds a top-level comment 

397 with threads_session(commenter_token) as api: 

398 comment_thread_id = api.PostReply( 

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

400 ).thread_id 

401 

402 # replier adds two nested replies 

403 with threads_session(replier_token) as api: 

404 reply_thread_id_1 = api.PostReply( 

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

406 ).thread_id 

407 reply_thread_id_2 = api.PostReply( 

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

409 ).thread_id 

410 

411 moderator.approve_thread_post(comment_thread_id) 

412 moderator.approve_thread_post(reply_thread_id_1) 

413 moderator.approve_thread_post(reply_thread_id_2) 

414 

415 # commenter deletes their top-level comment 

416 with threads_session(commenter_token) as api: 

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

418 

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

420 with threads_session(token) as api: 

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

422 assert len(thread_res.replies) == 1 

423 placeholder = thread_res.replies[0] 

424 assert placeholder.deleted 

425 assert placeholder.content == "" 

426 assert placeholder.author_user_id == 0 

427 

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

429 assert len(nested.replies) == 2 

430 

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

432 with discussions_session(token) as api: 

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

434 assert disc.thread.num_responses == 2 

435 

436 

437def test_admin_delete_discussion(db): 

438 user, token = generate_user() 

439 admin, admin_token = generate_user(is_superuser=True) 

440 with session_scope() as session: 

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

442 community_id = community.id 

443 

444 with discussions_session(token) as api: 

445 res = api.CreateDiscussion( 

446 discussions_pb2.CreateDiscussionReq( 

447 title="Admin will delete this", 

448 content="Content", 

449 owner_community_id=community_id, 

450 ) 

451 ) 

452 discussion_id = res.discussion_id 

453 

454 with real_admin_session(admin_token) as api: 

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

456 

457 with discussions_session(token) as api: 

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

459 assert res.deleted 

460 

461 

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

463 generate_user() 

464 user, token = generate_user() 

465 user2, token2 = generate_user() 

466 user3, token3 = generate_user() 

467 generate_user() 

468 generate_user() 

469 

470 with session_scope() as session: 

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

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

473 community_id = community.id 

474 user2_id = user2.id 

475 

476 with discussions_session(token) as api: 

477 time_before_create = now() 

478 res = api.CreateDiscussion( 

479 discussions_pb2.CreateDiscussionReq( 

480 title="dummy title", 

481 content="dummy content", 

482 owner_community_id=community_id, 

483 ) 

484 ) 

485 time_after_create = now() 

486 

487 assert res.title == "dummy title" 

488 assert res.content == "dummy content" 

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

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

491 assert res.creator_user_id == user.id 

492 assert res.owner_community_id == community_id 

493 

494 discussion_id = res.discussion_id 

495 thread_id = res.thread.thread_id 

496 

497 with threads_session(token2) as api: 

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

499 moderator.approve_thread_post(comment_thread_id) 

500 

501 with threads_session(token3) as api: 

502 reply_thread_id_a = api.PostReply( 

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

504 ).thread_id 

505 moderator.approve_thread_post(reply_thread_id_a) 

506 

507 with threads_session(token) as api: 

508 reply_thread_id_b = api.PostReply( 

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

510 ).thread_id 

511 moderator.approve_thread_post(reply_thread_id_b) 

512 

513 process_jobs() 

514 

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

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

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

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

519 

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

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

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

523 

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

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

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

527 

528 

529def test_create_discussion_creates_moderation_state(db): 

530 user, token = generate_user() 

531 

532 with session_scope() as session: 

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

534 community_id = community.id 

535 

536 with discussions_session(token) as api: 

537 res = api.CreateDiscussion( 

538 discussions_pb2.CreateDiscussionReq( 

539 title="t", 

540 content="c", 

541 owner_community_id=community_id, 

542 ) 

543 ) 

544 discussion_id = res.discussion_id 

545 

546 with session_scope() as session: 

547 state = session.execute( 

548 select(ModerationState) 

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

550 .where(ModerationState.object_id == discussion_id) 

551 ).scalar_one() 

552 assert state.visibility == ModerationVisibility.shadowed 

553 

554 

555def test_shadowed_discussion_visible_to_author_only(db): 

556 author, author_token = generate_user() 

557 _, other_token = generate_user() 

558 

559 with session_scope() as session: 

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

561 community_id = community.id 

562 

563 with discussions_session(author_token) as api: 

564 res = api.CreateDiscussion( 

565 discussions_pb2.CreateDiscussionReq( 

566 title="secret", 

567 content="secret content", 

568 owner_community_id=community_id, 

569 ) 

570 ) 

571 discussion_id = res.discussion_id 

572 

573 # Author can read their own shadowed discussion 

574 with discussions_session(author_token) as api: 

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

576 assert res.title == "secret" 

577 

578 # Other user cannot 

579 with discussions_session(other_token) as api: 

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

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

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

583 

584 

585def test_shadowed_discussion_excluded_from_listings(db): 

586 author, author_token = generate_user() 

587 other, other_token = generate_user() 

588 

589 with session_scope() as session: 

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

591 community_id = community.id 

592 

593 with discussions_session(author_token) as api: 

594 api.CreateDiscussion( 

595 discussions_pb2.CreateDiscussionReq( 

596 title="hidden-from-listings", 

597 content="c", 

598 owner_community_id=community_id, 

599 ) 

600 ) 

601 

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

603 with communities_session(other_token) as api: 

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

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

606 

607 

608def test_approved_discussion_visible_to_others(db, moderator: Moderator): 

609 author, author_token = generate_user() 

610 _, other_token = generate_user() 

611 

612 with session_scope() as session: 

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

614 community_id = community.id 

615 

616 with discussions_session(author_token) as api: 

617 res = api.CreateDiscussion( 

618 discussions_pb2.CreateDiscussionReq( 

619 title="hello", 

620 content="world", 

621 owner_community_id=community_id, 

622 ) 

623 ) 

624 discussion_id = res.discussion_id 

625 

626 moderator.approve_discussion(discussion_id) 

627 

628 with discussions_session(other_token) as api: 

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

630 assert res.title == "hello" 

631 

632 

633def test_hidden_discussion_filtered_for_author_too(db, moderator: Moderator): 

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

635 author, author_token = generate_user() 

636 

637 with session_scope() as session: 

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

639 community_id = community.id 

640 

641 with discussions_session(author_token) as api: 

642 res = api.CreateDiscussion( 

643 discussions_pb2.CreateDiscussionReq( 

644 title="bad", 

645 content="c", 

646 owner_community_id=community_id, 

647 ) 

648 ) 

649 discussion_id = res.discussion_id 

650 

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

652 with real_moderation_session(moderator.token) as api: 

653 state_res = api.GetModerationState( 

654 moderation_pb2.GetModerationStateReq( 

655 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_DISCUSSION, 

656 object_id=discussion_id, 

657 ) 

658 ) 

659 api.ModerateContent( 

660 moderation_pb2.ModerateContentReq( 

661 moderation_state_id=state_res.moderation_state.moderation_state_id, 

662 action=moderation_pb2.MODERATION_ACTION_HIDE, 

663 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

664 reason="bad content", 

665 ) 

666 ) 

667 

668 with discussions_session(author_token) as api: 

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

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

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

672 

673 

674def test_list_my_communities_discussions_respects_moderation(db, moderator: Moderator): 

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

676 author, author_token = generate_user() 

677 other, other_token = generate_user() 

678 

679 with session_scope() as session: 

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

681 community_id = community.id 

682 

683 with discussions_session(author_token) as api: 

684 discussion_id = api.CreateDiscussion( 

685 discussions_pb2.CreateDiscussionReq( 

686 title="hello", 

687 content="world", 

688 owner_community_id=community_id, 

689 ) 

690 ).discussion_id 

691 

692 # Author sees their own shadowed discussion 

693 with discussions_session(author_token) as api: 

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

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

696 

697 # Other members do not see the shadowed discussion 

698 with discussions_session(other_token) as api: 

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

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

701 

702 moderator.approve_discussion(discussion_id) 

703 

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

705 with discussions_session(other_token) as api: 

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

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

708 

709 

710def test_update_discussion_creates_version_record(db): 

711 user, token = generate_user() 

712 with session_scope() as session: 

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

714 community_id = community.id 

715 

716 with discussions_session(token) as api: 

717 res = api.CreateDiscussion( 

718 discussions_pb2.CreateDiscussionReq( 

719 title="Original title", 

720 content="Original content", 

721 owner_community_id=community_id, 

722 ) 

723 ) 

724 discussion_id = res.discussion_id 

725 

726 with discussions_session(token) as api: 

727 api.UpdateDiscussion( 

728 discussions_pb2.UpdateDiscussionReq( 

729 discussion_id=discussion_id, 

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

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

732 ) 

733 ) 

734 

735 with session_scope() as session: 

736 versions = ( 

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

738 .scalars() 

739 .all() 

740 ) 

741 assert len(versions) == 1 

742 v = versions[0] 

743 assert v.change_type == ContentChangeType.edit 

744 assert v.old_title == "Original title" 

745 assert v.new_title == "Updated title" 

746 assert v.old_content == "Original content" 

747 assert v.new_content == "Updated content" 

748 assert v.editor_user_id == user.id 

749 

750 

751def test_update_discussion_multiple_edits_creates_multiple_version_records(db): 

752 user, token = generate_user() 

753 with session_scope() as session: 

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

755 community_id = community.id 

756 

757 with discussions_session(token) as api: 

758 discussion_id = api.CreateDiscussion( 

759 discussions_pb2.CreateDiscussionReq( 

760 title="v1 title", 

761 content="v1 content", 

762 owner_community_id=community_id, 

763 ) 

764 ).discussion_id 

765 

766 with discussions_session(token) as api: 

767 api.UpdateDiscussion( 

768 discussions_pb2.UpdateDiscussionReq( 

769 discussion_id=discussion_id, 

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

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

772 ) 

773 ) 

774 

775 with discussions_session(token) as api: 

776 api.UpdateDiscussion( 

777 discussions_pb2.UpdateDiscussionReq( 

778 discussion_id=discussion_id, 

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

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

781 ) 

782 ) 

783 

784 with session_scope() as session: 

785 versions = ( 

786 session.execute( 

787 select(DiscussionVersion) 

788 .where(DiscussionVersion.discussion_id == discussion_id) 

789 .order_by(DiscussionVersion.id) 

790 ) 

791 .scalars() 

792 .all() 

793 ) 

794 assert len(versions) == 2 

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

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

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

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

799 

800 

801def test_delete_discussion_creates_version_record(db): 

802 user, token = generate_user() 

803 with session_scope() as session: 

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

805 community_id = community.id 

806 

807 with discussions_session(token) as api: 

808 discussion_id = api.CreateDiscussion( 

809 discussions_pb2.CreateDiscussionReq( 

810 title="To be deleted", 

811 content="Some content", 

812 owner_community_id=community_id, 

813 ) 

814 ).discussion_id 

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

816 

817 with session_scope() as session: 

818 versions = ( 

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

820 .scalars() 

821 .all() 

822 ) 

823 assert len(versions) == 1 

824 v = versions[0] 

825 assert v.change_type == ContentChangeType.delete 

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

827 assert v.new_title is None 

828 assert v.old_content == "Some content" 

829 assert v.new_content is None 

830 assert v.editor_user_id == user.id 

831 

832 

833def test_admin_delete_discussion_creates_version_record(db): 

834 user, token = generate_user() 

835 admin, admin_token = generate_user(is_superuser=True) 

836 with session_scope() as session: 

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

838 community_id = community.id 

839 

840 with discussions_session(token) as api: 

841 discussion_id = api.CreateDiscussion( 

842 discussions_pb2.CreateDiscussionReq( 

843 title="Admin will delete this", 

844 content="Some content", 

845 owner_community_id=community_id, 

846 ) 

847 ).discussion_id 

848 

849 with real_admin_session(admin_token) as api: 

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

851 

852 with session_scope() as session: 

853 versions = ( 

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

855 .scalars() 

856 .all() 

857 ) 

858 assert len(versions) == 1 

859 v = versions[0] 

860 assert v.change_type == ContentChangeType.delete 

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

862 assert v.new_title is None 

863 assert v.editor_user_id == admin.id