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

1925 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-03 11:56 +0000

1""" 

2Comprehensive tests for the Unified Moderation System (UMS) 

3""" 

4 

5import json 

6from datetime import datetime, timedelta 

7 

8import grpc 

9import pytest 

10from google.protobuf import empty_pb2, wrappers_pb2 

11from sqlalchemy.exc import IntegrityError 

12from sqlalchemy.sql import select 

13 

14from couchers import urls 

15from couchers.config import config 

16from couchers.constants import MODERATION_AUTO_APPROVE_FLAG_PRIORITY 

17from couchers.db import session_scope 

18from couchers.jobs.handlers import ( 

19 DatabaseInconsistencyError, 

20 auto_approve_moderation_queue, 

21 check_database_consistency, 

22) 

23from couchers.jobs.worker import process_job 

24from couchers.models import ( 

25 AdminAction, 

26 EventOccurrence, 

27 FriendRelationship, 

28 GroupChat, 

29 HostRequest, 

30 ModerationAction, 

31 ModerationLog, 

32 ModerationObjectType, 

33 ModerationQueueItem, 

34 ModerationState, 

35 ModerationTrigger, 

36 ModerationVisibility, 

37 Page, 

38 PageType, 

39 Reply, 

40 User, 

41 get_moderated_models, 

42) 

43from couchers.moderation.utils import create_moderation 

44from couchers.proto import ( 

45 admin_pb2, 

46 api_pb2, 

47 conversations_pb2, 

48 discussions_pb2, 

49 messages_pb2, 

50 moderation_pb2, 

51 notifications_pb2, 

52 pages_pb2, 

53 public_trips_pb2, 

54 requests_pb2, 

55 threads_pb2, 

56) 

57from couchers.servicers.threads import pack_thread_id 

58from couchers.utils import Timestamp_from_datetime, now, today 

59from tests.fixtures.api import events as events_api 

60from tests.fixtures.db import backdate_conversations, generate_user, make_friends 

61from tests.fixtures.misc import EmailCollector, PushCollector, process_jobs 

62from tests.fixtures.sessions import ( 

63 api_session, 

64 conversations_session, 

65 discussions_session, 

66 notifications_session, 

67 pages_session, 

68 public_trips_session, 

69 real_admin_session, 

70 real_moderation_session, 

71 requests_session, 

72 threads_session, 

73) 

74from tests.test_communities import create_community, create_group 

75from tests.test_public_trips import VALID_DESCRIPTION as VALID_TRIP_DESCRIPTION 

76from tests.test_references import create_friend_reference 

77from tests.test_requests import valid_request_text 

78 

79 

80def create_test_host_request_with_moderation(surfer_token, host_user_id): 

81 """Helper to create a host request and return its moderation state ID""" 

82 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

83 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

84 

85 with requests_session(surfer_token) as api: 

86 hr_id = api.CreateHostRequest( 

87 requests_pb2.CreateHostRequestReq( 

88 host_user_id=host_user_id, 

89 from_date=today_plus_2, 

90 to_date=today_plus_3, 

91 text=valid_request_text(), 

92 ) 

93 ).host_request_id 

94 

95 backdate_conversations() 

96 

97 with session_scope() as session: 

98 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one() 

99 return hr.moderation_state_id 

100 

101 

102# ============================================================================ 

103# Tests for moderation helper functions 

104# ============================================================================ 

105 

106 

107def test_create_moderation(db): 

108 """Test creating a moderation state with associated log entry""" 

109 user, _ = generate_user() 

110 

111 with session_scope() as session: 

112 # Create a moderation state 

113 moderation_state = create_moderation( 

114 session=session, 

115 object_type=ModerationObjectType.host_request, 

116 object_id=123, 

117 creator_user_id=user.id, 

118 ) 

119 

120 assert moderation_state.object_type == ModerationObjectType.host_request 

121 assert moderation_state.object_id == 123 

122 assert moderation_state.visibility == ModerationVisibility.shadowed 

123 

124 # Check that log entry was created 

125 log_entries = ( 

126 session.execute(select(ModerationLog).where(ModerationLog.moderation_state_id == moderation_state.id)) 

127 .scalars() 

128 .all() 

129 ) 

130 

131 assert len(log_entries) == 1 

132 assert log_entries[0].action == ModerationAction.create 

133 assert log_entries[0].reason == "Object created." 

134 assert log_entries[0].moderator_user_id == user.id 

135 

136 

137def test_add_to_moderation_queue(db): 

138 """Test adding content to moderation queue via API""" 

139 super_user, super_token = generate_user(is_superuser=True) 

140 user1, token1 = generate_user() 

141 user2, _ = generate_user() 

142 

143 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

144 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

145 

146 # Create a real host request (which automatically creates moderation state and adds to queue) 

147 with requests_session(token1) as api: 

148 host_request_id = api.CreateHostRequest( 

149 requests_pb2.CreateHostRequestReq( 

150 host_user_id=user2.id, 

151 from_date=today_plus_2, 

152 to_date=today_plus_3, 

153 text=valid_request_text(), 

154 ) 

155 ).host_request_id 

156 

157 # Get the moderation state ID 

158 state_id = None 

159 with session_scope() as session: 

160 host_request = session.execute( 

161 select(HostRequest).where(HostRequest.conversation_id == host_request_id) 

162 ).scalar_one() 

163 state_id = host_request.moderation_state_id 

164 

165 # Add another item to moderation queue via API (the first one was created automatically) 

166 with real_moderation_session(super_token) as api: 

167 api.ModerateContent( 

168 moderation_pb2.ModerateContentReq( 

169 moderation_state_id=state_id, 

170 action=moderation_pb2.MODERATION_ACTION_FLAG, 

171 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

172 reason="Admin manually flagged for additional review", 

173 ) 

174 ) 

175 

176 with session_scope() as session: 

177 flag = session.execute( 

178 select(ModerationQueueItem) 

179 .where(ModerationQueueItem.moderation_state_id == state_id) 

180 .where(ModerationQueueItem.trigger == ModerationTrigger.user_flag) 

181 ).scalar_one() 

182 assert flag.reason == "Admin manually flagged for additional review" 

183 assert flag.resolved_by_log_id is None 

184 

185 # The FLAG action is recorded in the log, pointing at the new queue item. 

186 flag_log = session.execute( 

187 select(ModerationLog) 

188 .where(ModerationLog.moderation_state_id == state_id) 

189 .where(ModerationLog.action == ModerationAction.flag) 

190 ).scalar_one() 

191 assert flag_log.queue_item_id == flag.id 

192 

193 

194def test_flag_with_data(db): 

195 """A flag can carry a JSON payload""" 

196 super_user, super_token = generate_user(is_superuser=True) 

197 user1, token1 = generate_user() 

198 user2, _ = generate_user() 

199 

200 state_id = create_test_host_request_with_moderation(token1, user2.id) 

201 

202 with real_moderation_session(super_token) as api: 

203 api.ModerateContent( 

204 moderation_pb2.ModerateContentReq( 

205 moderation_state_id=state_id, 

206 action=moderation_pb2.MODERATION_ACTION_FLAG, 

207 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG, 

208 reason="Automod", 

209 data=json.dumps({"classifier": "spam", "score": 0.97, "matches": ["buy now"]}), 

210 ) 

211 ) 

212 

213 res = api.GetModerationQueue( 

214 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG]) 

215 ) 

216 assert len(res.queue_items) == 1 

217 assert json.loads(res.queue_items[0].data) == { 

218 "classifier": "spam", 

219 "score": 0.97, 

220 "matches": ["buy now"], 

221 } 

222 

223 with session_scope() as session: 

224 flag = session.execute( 

225 select(ModerationQueueItem) 

226 .where(ModerationQueueItem.moderation_state_id == state_id) 

227 .where(ModerationQueueItem.trigger == ModerationTrigger.machine_flag) 

228 ).scalar_one() 

229 assert flag.data == {"classifier": "spam", "score": 0.97, "matches": ["buy now"]} 

230 

231 

232def test_flag_without_data(db): 

233 """A flag with no data serializes as an empty string""" 

234 super_user, super_token = generate_user(is_superuser=True) 

235 user1, token1 = generate_user() 

236 user2, _ = generate_user() 

237 

238 state_id = create_test_host_request_with_moderation(token1, user2.id) 

239 

240 with real_moderation_session(super_token) as api: 

241 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq()) 

242 assert len(res.queue_items) == 1 

243 assert res.queue_items[0].data == "" 

244 

245 with session_scope() as session: 

246 item = session.execute( 

247 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id) 

248 ).scalar_one() 

249 assert item.data is None 

250 

251 

252def test_flag_with_invalid_data(db): 

253 """Flag data must be valid JSON""" 

254 super_user, super_token = generate_user(is_superuser=True) 

255 user1, token1 = generate_user() 

256 user2, _ = generate_user() 

257 

258 state_id = create_test_host_request_with_moderation(token1, user2.id) 

259 

260 with real_moderation_session(super_token) as api: 

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

262 api.ModerateContent( 

263 moderation_pb2.ModerateContentReq( 

264 moderation_state_id=state_id, 

265 action=moderation_pb2.MODERATION_ACTION_FLAG, 

266 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG, 

267 reason="Automod", 

268 data="not json", 

269 ) 

270 ) 

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

272 assert e.value.details() == "The moderation data must be valid JSON." 

273 

274 

275def test_data_rejected_on_non_flag_action(db): 

276 """Data has nowhere to go on actions that don't create a queue item""" 

277 super_user, super_token = generate_user(is_superuser=True) 

278 user1, token1 = generate_user() 

279 user2, _ = generate_user() 

280 

281 state_id = create_test_host_request_with_moderation(token1, user2.id) 

282 

283 with real_moderation_session(super_token) as api: 

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

285 api.ModerateContent( 

286 moderation_pb2.ModerateContentReq( 

287 moderation_state_id=state_id, 

288 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

289 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

290 reason="Looks fine", 

291 data=json.dumps({"score": 0.1}), 

292 ) 

293 ) 

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

295 assert e.value.details() == "Moderation data can only be set when flagging." 

296 

297 

298def test_create_moderation_with_data(db): 

299 """create_moderation can attach data to the initial review item""" 

300 user, _ = generate_user() 

301 

302 with session_scope() as session: 

303 moderation_state = create_moderation( 

304 session=session, 

305 object_type=ModerationObjectType.host_request, 

306 object_id=123, 

307 creator_user_id=user.id, 

308 data={"classifier": "spam", "score": 0.42}, 

309 ) 

310 

311 item = session.execute( 

312 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == moderation_state.id) 

313 ).scalar_one() 

314 assert item.trigger == ModerationTrigger.initial_review 

315 assert item.data == {"classifier": "spam", "score": 0.42} 

316 

317 

318def test_moderate_content(db): 

319 """Test moderating content via API""" 

320 super_user, super_token = generate_user(is_superuser=True) 

321 user, token = generate_user() 

322 host, _ = generate_user() 

323 

324 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

325 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

326 

327 # Create a real host request 

328 state_id = None 

329 with requests_session(token) as api: 

330 hr_id = api.CreateHostRequest( 

331 requests_pb2.CreateHostRequestReq( 

332 host_user_id=host.id, 

333 from_date=today_plus_2, 

334 to_date=today_plus_3, 

335 text=valid_request_text(), 

336 ) 

337 ).host_request_id 

338 

339 with session_scope() as session: 

340 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one() 

341 state_id = hr.moderation_state_id 

342 

343 # Moderate the content via API 

344 with real_moderation_session(super_token) as api: 

345 res = api.ModerateContent( 

346 moderation_pb2.ModerateContentReq( 

347 moderation_state_id=state_id, 

348 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

349 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

350 reason="Content looks good", 

351 ) 

352 ) 

353 

354 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE 

355 

356 # Check that state was updated in database 

357 with session_scope() as session: 

358 updated_state = session.get_one(ModerationState, state_id) 

359 assert updated_state.visibility == ModerationVisibility.visible 

360 

361 # Check that log entry was created 

362 log_entries = ( 

363 session.execute( 

364 select(ModerationLog) 

365 .where(ModerationLog.moderation_state_id == state_id) 

366 .order_by(ModerationLog.time.desc(), ModerationLog.id.desc()) 

367 ) 

368 .scalars() 

369 .all() 

370 ) 

371 

372 assert len(log_entries) == 2 # CREATE + APPROVE 

373 assert log_entries[0].action == ModerationAction.approve 

374 assert log_entries[0].moderator_user_id == super_user.id 

375 assert log_entries[0].reason == "Content looks good" 

376 

377 

378def test_resolve_queue_item(db): 

379 """Test resolving a moderation queue item via ModerateContent API""" 

380 user1, token1 = generate_user() 

381 user2, _ = generate_user() 

382 moderator, moderator_token = generate_user(is_superuser=True) 

383 

384 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

385 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

386 

387 # Create a host request using the API (which automatically creates moderation state) 

388 with requests_session(token1) as api: 

389 host_request_id = api.CreateHostRequest( 

390 requests_pb2.CreateHostRequestReq( 

391 host_user_id=user2.id, 

392 from_date=today_plus_2, 

393 to_date=today_plus_3, 

394 text=valid_request_text(), 

395 ) 

396 ).host_request_id 

397 

398 state_id = None 

399 with session_scope() as session: 

400 # Get the host request and its moderation state 

401 host_request = session.execute( 

402 select(HostRequest).where(HostRequest.conversation_id == host_request_id) 

403 ).scalar_one() 

404 state_id = host_request.moderation_state_id 

405 

406 # The moderation state should already exist and be in the queue 

407 queue_item = session.execute( 

408 select(ModerationQueueItem) 

409 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id) 

410 .where(ModerationQueueItem.resolved_by_log_id.is_(None)) 

411 ).scalar_one() 

412 

413 assert queue_item.resolved_by_log_id is None 

414 

415 # Approve content with clear_flags, which resolves the open queue item(s) 

416 with real_moderation_session(moderator_token) as api: 

417 api.ModerateContent( 

418 moderation_pb2.ModerateContentReq( 

419 moderation_state_id=state_id, 

420 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

421 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

422 reason="Approved after review", 

423 clear_flags=True, 

424 ) 

425 ) 

426 

427 # Check that queue item was resolved 

428 with session_scope() as session: 

429 queue_item = session.execute( 

430 select(ModerationQueueItem) 

431 .where(ModerationQueueItem.moderation_state_id == state_id) 

432 .where(ModerationQueueItem.resolved_by_log_id.is_not(None)) 

433 ).scalar_one() 

434 assert queue_item.resolved_by_log_id is not None 

435 

436 

437def test_approve_content_via_api(db): 

438 """Test approving content via ModerateContent API""" 

439 user1, token1 = generate_user() 

440 user2, _ = generate_user() 

441 moderator, moderator_token = generate_user(is_superuser=True) 

442 

443 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

444 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

445 

446 # Create a host request using the API (which automatically creates moderation state) 

447 with requests_session(token1) as api: 

448 host_request_id = api.CreateHostRequest( 

449 requests_pb2.CreateHostRequestReq( 

450 host_user_id=user2.id, 

451 from_date=today_plus_2, 

452 to_date=today_plus_3, 

453 text=valid_request_text(), 

454 ) 

455 ).host_request_id 

456 

457 state_id = None 

458 with session_scope() as session: 

459 # Get the host request and its moderation state 

460 host_request = session.execute( 

461 select(HostRequest).where(HostRequest.conversation_id == host_request_id) 

462 ).scalar_one() 

463 state_id = host_request.moderation_state_id 

464 

465 # Approve via API 

466 with real_moderation_session(moderator_token) as api: 

467 api.ModerateContent( 

468 moderation_pb2.ModerateContentReq( 

469 moderation_state_id=state_id, 

470 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

471 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

472 reason="Quick approval", 

473 ) 

474 ) 

475 

476 # Check that state was updated to VISIBLE 

477 with session_scope() as session: 

478 updated_state = session.get_one(ModerationState, state_id) 

479 assert updated_state.visibility == ModerationVisibility.visible 

480 

481 # Check log entry 

482 log_entry = session.execute( 

483 select(ModerationLog) 

484 .where(ModerationLog.moderation_state_id == state_id) 

485 .where(ModerationLog.action == ModerationAction.approve) 

486 ).scalar_one() 

487 

488 assert log_entry.moderator_user_id == moderator.id 

489 assert log_entry.reason == "Quick approval" 

490 

491 

492# ============================================================================ 

493# Tests for host request moderation integration 

494# ============================================================================ 

495 

496 

497def test_create_host_request_creates_moderation_state(db): 

498 """Test that creating a host request automatically creates a moderation state""" 

499 user1, token1 = generate_user() 

500 user2, token2 = generate_user() 

501 

502 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

503 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

504 

505 with requests_session(token1) as api: 

506 host_request_id = api.CreateHostRequest( 

507 requests_pb2.CreateHostRequestReq( 

508 host_user_id=user2.id, 

509 from_date=today_plus_2, 

510 to_date=today_plus_3, 

511 text=valid_request_text(), 

512 ) 

513 ).host_request_id 

514 

515 with session_scope() as session: 

516 # Check that host request has a moderation state 

517 host_request = session.execute( 

518 select(HostRequest).where(HostRequest.conversation_id == host_request_id) 

519 ).scalar_one() 

520 

521 # Check moderation state properties 

522 moderation_state = session.execute( 

523 select(ModerationState).where(ModerationState.id == host_request.moderation_state_id) 

524 ).scalar_one() 

525 

526 assert moderation_state.object_type == ModerationObjectType.host_request 

527 assert moderation_state.object_id == host_request_id 

528 assert moderation_state.visibility == ModerationVisibility.shadowed 

529 

530 # Check that it was added to moderation queue 

531 queue_items = ( 

532 session.execute( 

533 select(ModerationQueueItem) 

534 .where(ModerationQueueItem.moderation_state_id == moderation_state.id) 

535 .where(ModerationQueueItem.resolved_by_log_id == None) 

536 ) 

537 .scalars() 

538 .all() 

539 ) 

540 

541 assert len(queue_items) == 1 

542 assert queue_items[0].trigger == ModerationTrigger.initial_review 

543 # item_author_user_id is no longer stored in the model, it's dynamically retrieved 

544 

545 

546def test_host_request_no_notification_before_approval(db, push_collector: PushCollector): 

547 """Test that host requests don't send notifications until approved""" 

548 user1, token1 = generate_user() 

549 user2, token2 = generate_user() 

550 

551 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

552 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

553 

554 with requests_session(token1) as api: 

555 host_request_id = api.CreateHostRequest( 

556 requests_pb2.CreateHostRequestReq( 

557 host_user_id=user2.id, 

558 from_date=today_plus_2, 

559 to_date=today_plus_3, 

560 text=valid_request_text(), 

561 ) 

562 ).host_request_id 

563 

564 # Process all jobs (including the notification job) 

565 process_jobs() 

566 

567 # No push notification should be sent yet (host requests are shadowed initially) 

568 assert push_collector.count_for_user(user2.id) == 0 

569 

570 

571def test_shadowed_notification_not_in_list_notifications(db): 

572 """Test that notifications for shadowed content don't appear in ListNotifications API""" 

573 user1, token1 = generate_user() 

574 user2, token2 = generate_user() 

575 

576 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

577 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

578 

579 # Create a host request (which creates a shadowed notification for the host) 

580 with requests_session(token1) as api: 

581 host_request_id = api.CreateHostRequest( 

582 requests_pb2.CreateHostRequestReq( 

583 host_user_id=user2.id, 

584 from_date=today_plus_2, 

585 to_date=today_plus_3, 

586 text=valid_request_text(), 

587 ) 

588 ).host_request_id 

589 

590 # Host (recipient) should NOT see the notification in ListNotifications - it's for shadowed content 

591 with notifications_session(token2) as api: 

592 res = api.ListNotifications(notifications_pb2.ListNotificationsReq()) 

593 # Should be empty - the host request is still shadowed 

594 assert len(res.notifications) == 0 

595 

596 

597def test_notification_visible_after_approval(db): 

598 """Test that notifications appear in ListNotifications after content is approved""" 

599 user1, token1 = generate_user() 

600 user2, token2 = generate_user() 

601 mod, mod_token = generate_user(is_superuser=True) 

602 

603 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

604 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

605 

606 # Create a host request (which creates a shadowed notification for the host) 

607 with requests_session(token1) as api: 

608 host_request_id = api.CreateHostRequest( 

609 requests_pb2.CreateHostRequestReq( 

610 host_user_id=user2.id, 

611 from_date=today_plus_2, 

612 to_date=today_plus_3, 

613 text=valid_request_text(), 

614 ) 

615 ).host_request_id 

616 

617 # Host (recipient) should NOT see the notification initially 

618 with notifications_session(token2) as api: 

619 res = api.ListNotifications(notifications_pb2.ListNotificationsReq()) 

620 assert len(res.notifications) == 0 

621 

622 # Get the moderation state ID and approve 

623 with session_scope() as session: 

624 host_request = session.execute( 

625 select(HostRequest).where(HostRequest.conversation_id == host_request_id) 

626 ).scalar_one() 

627 state_id = host_request.moderation_state_id 

628 

629 with real_moderation_session(mod_token) as api: 

630 api.ModerateContent( 

631 moderation_pb2.ModerateContentReq( 

632 moderation_state_id=state_id, 

633 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

634 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

635 reason="Looks good", 

636 ) 

637 ) 

638 

639 # Now host SHOULD see the notification 

640 with notifications_session(token2) as api: 

641 res = api.ListNotifications(notifications_pb2.ListNotificationsReq()) 

642 assert len(res.notifications) == 1 

643 assert res.notifications[0].topic == "host_request" 

644 assert res.notifications[0].action == "create" 

645 

646 

647def test_shadowed_host_request_visible_to_author_only(db): 

648 """Test that SHADOWED host requests are visible only to the author (surfer), not the recipient (host)""" 

649 user1, token1 = generate_user() 

650 user2, token2 = generate_user() 

651 

652 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

653 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

654 

655 with requests_session(token1) as api: 

656 host_request_id = api.CreateHostRequest( 

657 requests_pb2.CreateHostRequestReq( 

658 host_user_id=user2.id, 

659 from_date=today_plus_2, 

660 to_date=today_plus_3, 

661 text=valid_request_text(), 

662 ) 

663 ).host_request_id 

664 

665 # Surfer (author) can see it with GetHostRequest 

666 with requests_session(token1) as api: 

667 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

668 assert res.host_request_id == host_request_id 

669 assert res.latest_message.text.text == valid_request_text() 

670 

671 # Host (recipient) CANNOT see it with GetHostRequest - it's shadowed 

672 with requests_session(token2) as api: 

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

674 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

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

676 

677 

678def test_unlisted_host_request_not_in_lists(db): 

679 """Test that SHADOWED host requests are visible to author but not to recipient""" 

680 user1, token1 = generate_user() 

681 user2, token2 = generate_user() 

682 

683 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

684 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

685 

686 with requests_session(token1) as api: 

687 host_request_id = api.CreateHostRequest( 

688 requests_pb2.CreateHostRequestReq( 

689 host_user_id=user2.id, 

690 from_date=today_plus_2, 

691 to_date=today_plus_3, 

692 text=valid_request_text(), 

693 ) 

694 ).host_request_id 

695 

696 # Surfer (author) should see it in their sent list even though it's SHADOWED 

697 with requests_session(token1) as api: 

698 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True)) 

699 assert len(res.host_requests) == 1 

700 

701 # Host should NOT see it in their received list (still SHADOWED from them) 

702 with requests_session(token2) as api: 

703 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True)) 

704 assert len(res.host_requests) == 0 

705 

706 

707def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector): 

708 """Test that approved host requests appear in lists and send notifications""" 

709 user1, token1 = generate_user() 

710 user2, token2 = generate_user() 

711 mod, mod_token = generate_user(is_superuser=True) 

712 

713 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

714 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

715 

716 with requests_session(token1) as api: 

717 host_request_id = api.CreateHostRequest( 

718 requests_pb2.CreateHostRequestReq( 

719 host_user_id=user2.id, 

720 from_date=today_plus_2, 

721 to_date=today_plus_3, 

722 text=valid_request_text(), 

723 ) 

724 ).host_request_id 

725 

726 # Process the initial notification job - should be deferred (no notification sent) 

727 process_jobs() 

728 assert push_collector.count_for_user(user2.id) == 0 

729 

730 # Get the moderation state ID 

731 state_id = None 

732 with session_scope() as session: 

733 host_request = session.execute( 

734 select(HostRequest).where(HostRequest.conversation_id == host_request_id) 

735 ).scalar_one() 

736 state_id = host_request.moderation_state_id 

737 

738 # Approve the host request via API 

739 with real_moderation_session(mod_token) as api: 

740 api.ModerateContent( 

741 moderation_pb2.ModerateContentReq( 

742 moderation_state_id=state_id, 

743 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

744 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

745 reason="Looks good", 

746 ) 

747 ) 

748 

749 # Process the re-queued notification job - should now send notification 

750 process_jobs() 

751 

752 # Now surfer SHOULD see it in their sent list 

753 with requests_session(token1) as api: 

754 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True)) 

755 assert len(res.host_requests) == 1 

756 assert res.host_requests[0].host_request_id == host_request_id 

757 

758 # Host SHOULD see it in their received list 

759 with requests_session(token2) as api: 

760 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True)) 

761 assert len(res.host_requests) == 1 

762 assert res.host_requests[0].host_request_id == host_request_id 

763 

764 # After approval, the host should have received a push notification 

765 assert push_collector.pop_for_user(user2.id, last=True).topic_action == "host_request:create" 

766 

767 

768def test_hidden_host_request_invisible_to_all(db): 

769 """Test that HIDDEN host requests are invisible to everyone except moderators""" 

770 user1, token1 = generate_user() 

771 user2, token2 = generate_user() 

772 user3, token3 = generate_user() # Third party 

773 moderator, moderator_token = generate_user(is_superuser=True) 

774 

775 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

776 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

777 

778 with requests_session(token1) as api: 

779 host_request_id = api.CreateHostRequest( 

780 requests_pb2.CreateHostRequestReq( 

781 host_user_id=user2.id, 

782 from_date=today_plus_2, 

783 to_date=today_plus_3, 

784 text=valid_request_text(), 

785 ) 

786 ).host_request_id 

787 

788 # Get the moderation state ID 

789 state_id = None 

790 with session_scope() as session: 

791 host_request = session.execute( 

792 select(HostRequest).where(HostRequest.conversation_id == host_request_id) 

793 ).scalar_one() 

794 state_id = host_request.moderation_state_id 

795 

796 # Hide the host request via API (e.g., spam/abuse) 

797 with real_moderation_session(moderator_token) as api: 

798 api.ModerateContent( 

799 moderation_pb2.ModerateContentReq( 

800 moderation_state_id=state_id, 

801 action=moderation_pb2.MODERATION_ACTION_HIDE, 

802 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

803 reason="Spam content", 

804 ) 

805 ) 

806 

807 # Surfer can't see it with GetHostRequest 

808 with requests_session(token1) as api: 

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

810 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

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

812 

813 # Host can't see it with GetHostRequest 

814 with requests_session(token2) as api: 

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

816 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

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

818 

819 # Third party definitely can't see it 

820 with requests_session(token3) as api: 

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

822 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

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

824 

825 # Not in any lists 

826 with requests_session(token1) as api: 

827 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True)) 

828 assert len(res.host_requests) == 0 

829 

830 with requests_session(token2) as api: 

831 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True)) 

832 assert len(res.host_requests) == 0 

833 

834 

835def test_multiple_host_requests_listing_visibility(db): 

836 """Test that ListHostRequests correctly filters based on moderation state""" 

837 user1, token1 = generate_user() 

838 user2, token2 = generate_user() 

839 moderator, moderator_token = generate_user(is_superuser=True) 

840 

841 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

842 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

843 

844 # Create 3 host requests 

845 host_request_ids = [] 

846 state_ids = [] 

847 with requests_session(token1) as api: 

848 for i in range(3): 

849 hr_id = api.CreateHostRequest( 

850 requests_pb2.CreateHostRequestReq( 

851 host_user_id=user2.id, 

852 from_date=today_plus_2, 

853 to_date=today_plus_3, 

854 text=valid_request_text(f"Test request {i + 1}"), 

855 ) 

856 ).host_request_id 

857 host_request_ids.append(hr_id) 

858 backdate_conversations() 

859 

860 # Get state IDs 

861 with session_scope() as session: 

862 for hr_id in host_request_ids: 

863 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one() 

864 state_ids.append(host_request.moderation_state_id) 

865 

866 # Approve the first one via API 

867 with real_moderation_session(moderator_token) as api: 

868 api.ModerateContent( 

869 moderation_pb2.ModerateContentReq( 

870 moderation_state_id=state_ids[0], 

871 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

872 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

873 reason="Approved", 

874 ) 

875 ) 

876 

877 # Hide the third one via API 

878 with real_moderation_session(moderator_token) as api: 

879 api.ModerateContent( 

880 moderation_pb2.ModerateContentReq( 

881 moderation_state_id=state_ids[2], 

882 action=moderation_pb2.MODERATION_ACTION_HIDE, 

883 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

884 reason="Spam", 

885 ) 

886 ) 

887 

888 # Surfer should see the approved one and the shadowed one (author can see their SHADOWED content) 

889 with requests_session(token1) as api: 

890 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True)) 

891 assert len(res.host_requests) == 2 

892 visible_ids = {hr.host_request_id for hr in res.host_requests} 

893 assert visible_ids == {host_request_ids[0], host_request_ids[1]} 

894 

895 # Host should see only the approved one in received list 

896 with requests_session(token2) as api: 

897 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True)) 

898 assert len(res.host_requests) == 1 

899 assert res.host_requests[0].host_request_id == host_request_ids[0] 

900 

901 

902def test_moderation_log_tracking(db): 

903 """Test that moderation actions are properly logged via API""" 

904 user, user_token = generate_user() 

905 host, _ = generate_user() 

906 moderator1, moderator1_token = generate_user(is_superuser=True) 

907 moderator2, moderator2_token = generate_user(is_superuser=True) 

908 

909 # Create a real host request 

910 state_id = create_test_host_request_with_moderation(user_token, host.id) 

911 

912 # Perform several moderation actions via API 

913 with real_moderation_session(moderator1_token) as api: 

914 api.ModerateContent( 

915 moderation_pb2.ModerateContentReq( 

916 moderation_state_id=state_id, 

917 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

918 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

919 reason="Looks good initially", 

920 ) 

921 ) 

922 

923 with real_moderation_session(moderator2_token) as api: 

924 api.ModerateContent( 

925 moderation_pb2.ModerateContentReq( 

926 moderation_state_id=state_id, 

927 action=moderation_pb2.MODERATION_ACTION_FLAG, 

928 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

929 reason="Wait, this needs another look", 

930 ) 

931 ) 

932 # Shadow it back 

933 api.ModerateContent( 

934 moderation_pb2.ModerateContentReq( 

935 moderation_state_id=state_id, 

936 action=moderation_pb2.MODERATION_ACTION_HIDE, 

937 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

938 reason="Wait, this needs another look", 

939 ) 

940 ) 

941 

942 with real_moderation_session(moderator1_token) as api: 

943 api.ModerateContent( 

944 moderation_pb2.ModerateContentReq( 

945 moderation_state_id=state_id, 

946 action=moderation_pb2.MODERATION_ACTION_HIDE, 

947 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

948 reason="Actually it's spam", 

949 ) 

950 ) 

951 

952 # Check all log entries 

953 with session_scope() as session: 

954 log_entries = ( 

955 session.execute( 

956 select(ModerationLog) 

957 .where(ModerationLog.moderation_state_id == state_id) 

958 .order_by(ModerationLog.time.asc()) 

959 ) 

960 .scalars() 

961 .all() 

962 ) 

963 

964 # CREATE + APPROVE + HIDE + HIDE (shadowing back counts as HIDE action) 

965 assert len(log_entries) >= 3 

966 

967 assert log_entries[0].action == ModerationAction.create 

968 assert log_entries[0].moderator_user_id == user.id 

969 assert log_entries[0].reason == "Object created." 

970 

971 assert log_entries[1].action == ModerationAction.approve 

972 assert log_entries[1].moderator_user_id == moderator1.id 

973 assert log_entries[1].reason == "Looks good initially" 

974 

975 # The last action should be hiding 

976 assert log_entries[-1].action == ModerationAction.hide 

977 assert log_entries[-1].moderator_user_id == moderator1.id 

978 assert log_entries[-1].reason == "Actually it's spam" 

979 

980 

981def test_moderation_queue_workflow(db): 

982 """Test the full moderation queue workflow via API""" 

983 user1, token1 = generate_user() 

984 user2, _ = generate_user() 

985 moderator, moderator_token = generate_user(is_superuser=True) 

986 

987 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

988 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

989 

990 # Create a host request using the API (which automatically creates moderation state and adds to queue) 

991 with requests_session(token1) as api: 

992 host_request_id = api.CreateHostRequest( 

993 requests_pb2.CreateHostRequestReq( 

994 host_user_id=user2.id, 

995 from_date=today_plus_2, 

996 to_date=today_plus_3, 

997 text=valid_request_text(), 

998 ) 

999 ).host_request_id 

1000 

1001 state_id = None 

1002 queue_item_id = None 

1003 with session_scope() as session: 

1004 # Get the host request and its moderation state 

1005 host_request = session.execute( 

1006 select(HostRequest).where(HostRequest.conversation_id == host_request_id) 

1007 ).scalar_one() 

1008 state_id = host_request.moderation_state_id 

1009 

1010 # The queue item should already exist (created automatically) 

1011 queue_item = session.execute( 

1012 select(ModerationQueueItem) 

1013 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id) 

1014 .where(ModerationQueueItem.resolved_by_log_id.is_(None)) 

1015 ).scalar_one() 

1016 queue_item_id = queue_item.id 

1017 

1018 # Verify it's in the queue 

1019 unresolved_items = ( 

1020 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None)) 

1021 .scalars() 

1022 .all() 

1023 ) 

1024 

1025 assert len(unresolved_items) >= 1 

1026 assert queue_item.id in [item.id for item in unresolved_items] 

1027 

1028 # Moderator reviews and approves via API, clearing the open queue item 

1029 with real_moderation_session(moderator_token) as api: 

1030 api.ModerateContent( 

1031 moderation_pb2.ModerateContentReq( 

1032 moderation_state_id=state_id, 

1033 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1034 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1035 reason="Content approved", 

1036 clear_flags=True, 

1037 ) 

1038 ) 

1039 

1040 # Verify queue item was resolved 

1041 with session_scope() as session: 

1042 # Verify it's no longer in unresolved queue 

1043 unresolved_items = ( 

1044 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None)) 

1045 .scalars() 

1046 .all() 

1047 ) 

1048 

1049 assert queue_item_id not in [item.id for item in unresolved_items] 

1050 

1051 # Verify the queue item was linked to a log entry 

1052 queue_item = session.get_one(ModerationQueueItem, queue_item_id) 

1053 assert queue_item.resolved_by_log_id is not None 

1054 

1055 

1056# ============================================================================ 

1057# Moderation API Tests (testing the gRPC servicer) 

1058# ============================================================================ 

1059 

1060 

1061def test_GetModerationQueue_empty(db): 

1062 """Test getting an empty moderation queue""" 

1063 super_user, super_token = generate_user(is_superuser=True) 

1064 

1065 with real_moderation_session(super_token) as api: 

1066 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq()) 

1067 assert len(res.queue_items) == 0 

1068 assert res.next_page_token == "" 

1069 

1070 

1071def test_GetModerationQueue_with_items(db): 

1072 """Test getting moderation queue with items via API""" 

1073 super_user, super_token = generate_user(is_superuser=True) 

1074 normal_user, user_token = generate_user() 

1075 host, _ = generate_user() 

1076 

1077 # Create some host requests (which automatically adds them to moderation queue) 

1078 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1079 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1080 

1081 with real_moderation_session(super_token) as api: 

1082 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq()) 

1083 assert len(res.queue_items) == 2 

1084 assert res.queue_items[0].is_resolved == False 

1085 assert res.queue_items[1].is_resolved == False 

1086 

1087 

1088def test_GetModerationQueue_filter_by_trigger(db): 

1089 """Test filtering moderation queue by trigger type via API""" 

1090 super_user, super_token = generate_user(is_superuser=True) 

1091 normal_user, user_token = generate_user() 

1092 host, _ = generate_user() 

1093 

1094 # Create host requests (which automatically adds them to moderation queue with INITIAL_REVIEW) 

1095 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1096 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1097 

1098 # Add USER_FLAG trigger to second item via API 

1099 with real_moderation_session(super_token) as api: 

1100 api.ModerateContent( 

1101 moderation_pb2.ModerateContentReq( 

1102 moderation_state_id=state2_id, 

1103 action=moderation_pb2.MODERATION_ACTION_FLAG, 

1104 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

1105 reason="Reported by user", 

1106 ) 

1107 ) 

1108 

1109 # Filter by INITIAL_REVIEW (should get first item and maybe both depending on how queue works) 

1110 with real_moderation_session(super_token) as api: 

1111 res = api.GetModerationQueue( 

1112 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW]) 

1113 ) 

1114 assert len(res.queue_items) == 2 # Both have INITIAL_REVIEW triggers 

1115 assert all(item.trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW for item in res.queue_items) 

1116 

1117 # Filter by USER_FLAG (should get second item only) 

1118 with real_moderation_session(super_token) as api: 

1119 res = api.GetModerationQueue( 

1120 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_USER_FLAG]) 

1121 ) 

1122 assert len(res.queue_items) == 1 

1123 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG 

1124 

1125 

1126def test_GetModerationQueue_filter_created_before(db): 

1127 """Test filtering moderation queue by created_before timestamp""" 

1128 super_user, super_token = generate_user(is_superuser=True) 

1129 normal_user, user_token = generate_user() 

1130 host, _ = generate_user() 

1131 

1132 # Create host requests 

1133 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1134 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1135 

1136 # Backdate the first queue item 

1137 with session_scope() as session: 

1138 queue_item1 = session.execute( 

1139 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id) 

1140 ).scalar_one() 

1141 # Set it to 2 hours ago 

1142 queue_item1.time_created = now() - timedelta(hours=2) 

1143 

1144 # The second item remains at current time 

1145 

1146 # Filter to items created before 1 hour ago (should only get the first item) 

1147 cutoff_time = now() - timedelta(hours=1) 

1148 with real_moderation_session(super_token) as api: 

1149 res = api.GetModerationQueue( 

1150 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(cutoff_time)) 

1151 ) 

1152 assert len(res.queue_items) == 1 

1153 assert res.queue_items[0].moderation_state_id == state1_id 

1154 

1155 # Filter to items created before now (should get both) 

1156 with real_moderation_session(super_token) as api: 

1157 res = api.GetModerationQueue( 

1158 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(now() + timedelta(seconds=10))) 

1159 ) 

1160 assert len(res.queue_items) == 2 

1161 

1162 # Filter to items created before 3 hours ago (should get none) 

1163 old_cutoff = now() - timedelta(hours=3) 

1164 with real_moderation_session(super_token) as api: 

1165 res = api.GetModerationQueue( 

1166 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(old_cutoff)) 

1167 ) 

1168 assert len(res.queue_items) == 0 

1169 

1170 

1171def test_GetModerationQueue_filter_created_after(db): 

1172 """Test filtering moderation queue by created_after timestamp""" 

1173 super_user, super_token = generate_user(is_superuser=True) 

1174 normal_user, user_token = generate_user() 

1175 host, _ = generate_user() 

1176 

1177 # Create host requests 

1178 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1179 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1180 

1181 # Backdate the first queue item to 2 hours ago 

1182 with session_scope() as session: 

1183 queue_item1 = session.execute( 

1184 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id) 

1185 ).scalar_one() 

1186 queue_item1.time_created = now() - timedelta(hours=2) 

1187 

1188 # The second item remains at current time 

1189 

1190 # Filter to items created after 1 hour ago (should only get the second item) 

1191 cutoff_time = now() - timedelta(hours=1) 

1192 with real_moderation_session(super_token) as api: 

1193 res = api.GetModerationQueue( 

1194 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(cutoff_time)) 

1195 ) 

1196 assert len(res.queue_items) == 1 

1197 assert res.queue_items[0].moderation_state_id == state2_id 

1198 

1199 # Filter to items created after 3 hours ago (should get both) 

1200 old_cutoff = now() - timedelta(hours=3) 

1201 with real_moderation_session(super_token) as api: 

1202 res = api.GetModerationQueue( 

1203 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(old_cutoff)) 

1204 ) 

1205 assert len(res.queue_items) == 2 

1206 

1207 # Filter to items created after now (should get none) 

1208 with real_moderation_session(super_token) as api: 

1209 res = api.GetModerationQueue( 

1210 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(now() + timedelta(seconds=10))) 

1211 ) 

1212 assert len(res.queue_items) == 0 

1213 

1214 

1215def test_GetModerationQueue_filter_created_before_and_after(db): 

1216 """Test filtering moderation queue by both created_before and created_after timestamps""" 

1217 super_user, super_token = generate_user(is_superuser=True) 

1218 normal_user, user_token = generate_user() 

1219 host, _ = generate_user() 

1220 

1221 # Create 3 host requests 

1222 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1223 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1224 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1225 

1226 # Set different times: state1 = 3 hours ago, state2 = 1.5 hours ago, state3 = now 

1227 with session_scope() as session: 

1228 queue_item1 = session.execute( 

1229 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id) 

1230 ).scalar_one() 

1231 queue_item1.time_created = now() - timedelta(hours=3) 

1232 

1233 queue_item2 = session.execute( 

1234 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id) 

1235 ).scalar_one() 

1236 queue_item2.time_created = now() - timedelta(hours=1, minutes=30) 

1237 

1238 # Filter to items between 2 hours ago and 1 hour ago (should only get state2) 

1239 after_cutoff = now() - timedelta(hours=2) 

1240 before_cutoff = now() - timedelta(hours=1) 

1241 with real_moderation_session(super_token) as api: 

1242 res = api.GetModerationQueue( 

1243 moderation_pb2.GetModerationQueueReq( 

1244 created_after=Timestamp_from_datetime(after_cutoff), 

1245 created_before=Timestamp_from_datetime(before_cutoff), 

1246 ) 

1247 ) 

1248 assert len(res.queue_items) == 1 

1249 assert res.queue_items[0].moderation_state_id == state2_id 

1250 

1251 # Filter to items between 4 hours ago and 2.5 hours ago (should only get state1) 

1252 after_cutoff = now() - timedelta(hours=4) 

1253 before_cutoff = now() - timedelta(hours=2, minutes=30) 

1254 with real_moderation_session(super_token) as api: 

1255 res = api.GetModerationQueue( 

1256 moderation_pb2.GetModerationQueueReq( 

1257 created_after=Timestamp_from_datetime(after_cutoff), 

1258 created_before=Timestamp_from_datetime(before_cutoff), 

1259 ) 

1260 ) 

1261 assert len(res.queue_items) == 1 

1262 assert res.queue_items[0].moderation_state_id == state1_id 

1263 

1264 

1265def test_GetModerationQueue_filter_unresolved(db): 

1266 """Test filtering moderation queue for unresolved items only via API""" 

1267 super_user, super_token = generate_user(is_superuser=True) 

1268 normal_user, user_token = generate_user() 

1269 host, _ = generate_user() 

1270 

1271 # Create 2 host requests 

1272 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1273 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1274 

1275 # Resolve the first one via API (approve with clear_flags resolves the queue item) 

1276 with real_moderation_session(super_token) as api: 

1277 api.ModerateContent( 

1278 moderation_pb2.ModerateContentReq( 

1279 moderation_state_id=state1_id, 

1280 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1281 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1282 reason="Approved", 

1283 clear_flags=True, 

1284 ) 

1285 ) 

1286 

1287 # Get all items 

1288 with real_moderation_session(super_token) as api: 

1289 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq()) 

1290 assert len(res.queue_items) == 2 

1291 

1292 # Get only unresolved items 

1293 with real_moderation_session(super_token) as api: 

1294 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True)) 

1295 assert len(res.queue_items) == 1 

1296 assert res.queue_items[0].is_resolved == False 

1297 

1298 

1299def test_GetModerationQueue_filter_by_author(db): 

1300 """Test filtering moderation queue by item_author_user_id""" 

1301 super_user, super_token = generate_user(is_superuser=True) 

1302 user1, token1 = generate_user() 

1303 user2, token2 = generate_user() 

1304 host_user, _ = generate_user() 

1305 

1306 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

1307 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

1308 

1309 # Create 2 host requests by user1 

1310 with requests_session(token1) as api: 

1311 hr1_id = api.CreateHostRequest( 

1312 requests_pb2.CreateHostRequestReq( 

1313 host_user_id=host_user.id, 

1314 from_date=today_plus_2, 

1315 to_date=today_plus_3, 

1316 text=valid_request_text(), 

1317 ) 

1318 ).host_request_id 

1319 backdate_conversations() 

1320 

1321 hr2_id = api.CreateHostRequest( 

1322 requests_pb2.CreateHostRequestReq( 

1323 host_user_id=host_user.id, 

1324 from_date=today_plus_2, 

1325 to_date=today_plus_3, 

1326 text=valid_request_text(), 

1327 ) 

1328 ).host_request_id 

1329 

1330 # Create 1 host request by user2 

1331 with requests_session(token2) as api: 

1332 hr3_id = api.CreateHostRequest( 

1333 requests_pb2.CreateHostRequestReq( 

1334 host_user_id=host_user.id, 

1335 from_date=today_plus_2, 

1336 to_date=today_plus_3, 

1337 text=valid_request_text(), 

1338 ) 

1339 ).host_request_id 

1340 

1341 # Get moderation state IDs 

1342 state1_id, state2_id, state3_id = None, None, None 

1343 with session_scope() as session: 

1344 hr1 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr1_id)).scalar_one() 

1345 hr2 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr2_id)).scalar_one() 

1346 hr3 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr3_id)).scalar_one() 

1347 state1_id = hr1.moderation_state_id 

1348 state2_id = hr2.moderation_state_id 

1349 state3_id = hr3.moderation_state_id 

1350 

1351 # Get all items (should be 3) 

1352 with real_moderation_session(super_token) as api: 

1353 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq()) 

1354 assert len(res.queue_items) == 3 

1355 

1356 # Filter by user1 (should get 2) 

1357 with real_moderation_session(super_token) as api: 

1358 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user1.id)) 

1359 assert len(res.queue_items) == 2 

1360 assert all(item.moderation_state.author.user_id == user1.id for item in res.queue_items) 

1361 

1362 # Filter by user2 (should get 1) 

1363 with real_moderation_session(super_token) as api: 

1364 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user2.id)) 

1365 assert len(res.queue_items) == 1 

1366 assert res.queue_items[0].moderation_state.author.user_id == user2.id 

1367 assert res.queue_items[0].moderation_state_id == state3_id 

1368 

1369 # Filter by non-existent user (should get 0) 

1370 with real_moderation_session(super_token) as api: 

1371 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=999999)) 

1372 assert len(res.queue_items) == 0 

1373 

1374 

1375def test_GetModerationQueue_ordering(db): 

1376 """Test ordering moderation queue by oldest/newest first""" 

1377 super_user, super_token = generate_user(is_superuser=True) 

1378 normal_user, user_token = generate_user() 

1379 host, _ = generate_user() 

1380 

1381 # Create 3 host requests 

1382 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1383 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1384 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1385 

1386 # Set different times: state1 = 3 hours ago, state2 = 2 hours ago, state3 = 1 hour ago 

1387 with session_scope() as session: 

1388 queue_item1 = session.execute( 

1389 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id) 

1390 ).scalar_one() 

1391 queue_item1.time_created = now() - timedelta(hours=3) 

1392 

1393 queue_item2 = session.execute( 

1394 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id) 

1395 ).scalar_one() 

1396 queue_item2.time_created = now() - timedelta(hours=2) 

1397 

1398 queue_item3 = session.execute( 

1399 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state3_id) 

1400 ).scalar_one() 

1401 queue_item3.time_created = now() - timedelta(hours=1) 

1402 

1403 # Default order (oldest first) 

1404 with real_moderation_session(super_token) as api: 

1405 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq()) 

1406 assert len(res.queue_items) == 3 

1407 assert res.queue_items[0].moderation_state_id == state1_id # oldest 

1408 assert res.queue_items[1].moderation_state_id == state2_id 

1409 assert res.queue_items[2].moderation_state_id == state3_id # newest 

1410 

1411 # Explicit oldest first 

1412 with real_moderation_session(super_token) as api: 

1413 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=False)) 

1414 assert len(res.queue_items) == 3 

1415 assert res.queue_items[0].moderation_state_id == state1_id # oldest 

1416 assert res.queue_items[2].moderation_state_id == state3_id # newest 

1417 

1418 # Newest first 

1419 with real_moderation_session(super_token) as api: 

1420 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=True)) 

1421 assert len(res.queue_items) == 3 

1422 assert res.queue_items[0].moderation_state_id == state3_id # newest 

1423 assert res.queue_items[1].moderation_state_id == state2_id 

1424 assert res.queue_items[2].moderation_state_id == state1_id # oldest 

1425 

1426 

1427def test_GetModerationQueue_pagination_newest_first(db): 

1428 """Test pagination with newest_first=True returns different items on each page""" 

1429 super_user, super_token = generate_user(is_superuser=True) 

1430 normal_user, normal_token = generate_user() 

1431 host_user, _ = generate_user() 

1432 

1433 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

1434 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

1435 

1436 # Create 5 host requests 

1437 hr_ids = [] 

1438 with requests_session(normal_token) as api: 

1439 for i in range(5): 

1440 hr_id = api.CreateHostRequest( 

1441 requests_pb2.CreateHostRequestReq( 

1442 host_user_id=host_user.id, 

1443 from_date=today_plus_2, 

1444 to_date=today_plus_3, 

1445 text=valid_request_text(), 

1446 ) 

1447 ).host_request_id 

1448 hr_ids.append(hr_id) 

1449 backdate_conversations() 

1450 

1451 # Get moderation state IDs 

1452 state_ids = [] 

1453 with session_scope() as session: 

1454 for hr_id in hr_ids: 

1455 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one() 

1456 state_ids.append(hr.moderation_state_id) 

1457 

1458 # Set different times so ordering is deterministic 

1459 with session_scope() as session: 

1460 for i, state_id in enumerate(state_ids): 

1461 queue_item = session.execute( 

1462 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id) 

1463 ).scalar_one() 

1464 queue_item.time_created = now() - timedelta(hours=5 - i) # oldest first in list 

1465 

1466 # Get first page (2 items) with newest_first=True, filtered to our user's items 

1467 with real_moderation_session(super_token) as api: 

1468 res1 = api.GetModerationQueue( 

1469 moderation_pb2.GetModerationQueueReq(page_size=2, newest_first=True, item_author_user_id=normal_user.id) 

1470 ) 

1471 assert len(res1.queue_items) == 2 

1472 # Should get newest items: state_ids[4], state_ids[3] 

1473 assert res1.queue_items[0].moderation_state_id == state_ids[4] 

1474 assert res1.queue_items[1].moderation_state_id == state_ids[3] 

1475 assert res1.next_page_token # should have more pages 

1476 

1477 # Get second page using the token 

1478 res2 = api.GetModerationQueue( 

1479 moderation_pb2.GetModerationQueueReq( 

1480 page_size=2, newest_first=True, page_token=res1.next_page_token, item_author_user_id=normal_user.id 

1481 ) 

1482 ) 

1483 assert len(res2.queue_items) == 2 

1484 # Should get next newest items: state_ids[2], state_ids[1] 

1485 assert res2.queue_items[0].moderation_state_id == state_ids[2] 

1486 assert res2.queue_items[1].moderation_state_id == state_ids[1] 

1487 

1488 # Pages should not overlap 

1489 page1_ids = {item.moderation_state_id for item in res1.queue_items} 

1490 page2_ids = {item.moderation_state_id for item in res2.queue_items} 

1491 assert page1_ids.isdisjoint(page2_ids), "Pages should not have overlapping items" 

1492 

1493 

1494def test_GetModerationQueue_pagination_ignores_time_created(db): 

1495 """Backfilled queue items were given ids out of a separate range, so time_created and id disagree.""" 

1496 super_user, super_token = generate_user(is_superuser=True) 

1497 normal_user, normal_token = generate_user() 

1498 host, _ = generate_user() 

1499 

1500 first_state_id = create_test_host_request_with_moderation(normal_token, host.id) 

1501 create_test_host_request_with_moderation(normal_token, host.id) 

1502 create_test_host_request_with_moderation(normal_token, host.id) 

1503 

1504 with session_scope() as session: 

1505 item = session.execute( 

1506 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == first_state_id) 

1507 ).scalar_one() 

1508 item.time_created = now() + timedelta(days=1) 

1509 

1510 paged_ids = [] 

1511 page_token = "" 

1512 with real_moderation_session(super_token) as api: 

1513 while True: 

1514 res = api.GetModerationQueue( 

1515 moderation_pb2.GetModerationQueueReq(page_size=2, page_token=page_token, newest_first=True) 

1516 ) 

1517 paged_ids += [item.queue_item_id for item in res.queue_items] 

1518 page_token = res.next_page_token 

1519 if not page_token: 

1520 break 

1521 

1522 with session_scope() as session: 

1523 expected = list( 

1524 session.execute(select(ModerationQueueItem.id).order_by(ModerationQueueItem.id.desc())).scalars().all() 

1525 ) 

1526 

1527 assert paged_ids == expected 

1528 

1529 

1530def test_GetModerationLog(db): 

1531 """Test getting moderation log for a state via API""" 

1532 super_user, super_token = generate_user(is_superuser=True) 

1533 moderator, moderator_token = generate_user(is_superuser=True) 

1534 normal_user, user_token = generate_user() 

1535 host, _ = generate_user() 

1536 

1537 # Create a real host request 

1538 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1539 

1540 # Perform a moderation action via API 

1541 with real_moderation_session(moderator_token) as api: 

1542 api.ModerateContent( 

1543 moderation_pb2.ModerateContentReq( 

1544 moderation_state_id=state_id, 

1545 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1546 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1547 reason="Looks good", 

1548 ) 

1549 ) 

1550 

1551 with real_moderation_session(super_token) as api: 

1552 res = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id)) 

1553 assert len(res.log_entries) == 2 # CREATE + APPROVE 

1554 assert res.moderation_state.moderation_state_id == state_id 

1555 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE 

1556 # Log entries are in reverse chronological order 

1557 assert res.log_entries[0].action == moderation_pb2.MODERATION_ACTION_APPROVE 

1558 assert res.log_entries[0].moderator_user_id == moderator.id 

1559 assert res.log_entries[0].reason == "Looks good" 

1560 assert res.log_entries[1].action == moderation_pb2.MODERATION_ACTION_CREATE 

1561 assert res.log_entries[1].moderator_user_id == normal_user.id 

1562 

1563 

1564def test_GetModerationLog_not_found(db): 

1565 """Test getting moderation log for non-existent state""" 

1566 super_user, super_token = generate_user(is_superuser=True) 

1567 

1568 with real_moderation_session(super_token) as api: 

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

1570 api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=999999)) 

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

1572 assert e.value.details() == "Moderation state not found." 

1573 

1574 

1575def test_GetModerationState(db): 

1576 """Test getting moderation state by object type and ID""" 

1577 super_user, super_token = generate_user(is_superuser=True) 

1578 user1, token1 = generate_user() 

1579 user2, _ = generate_user() 

1580 

1581 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

1582 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

1583 

1584 with requests_session(token1) as api: 

1585 host_request_id = api.CreateHostRequest( 

1586 requests_pb2.CreateHostRequestReq( 

1587 host_user_id=user2.id, 

1588 from_date=today_plus_2, 

1589 to_date=today_plus_3, 

1590 text=valid_request_text(), 

1591 ) 

1592 ).host_request_id 

1593 

1594 with real_moderation_session(super_token) as api: 

1595 res = api.GetModerationState( 

1596 moderation_pb2.GetModerationStateReq( 

1597 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1598 object_id=host_request_id, 

1599 ) 

1600 ) 

1601 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST 

1602 assert res.moderation_state.object_id == host_request_id 

1603 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED 

1604 assert res.moderation_state.moderation_state_id > 0 

1605 

1606 

1607def test_GetModerationState_not_found(db): 

1608 """Test getting moderation state for non-existent object""" 

1609 super_user, super_token = generate_user(is_superuser=True) 

1610 

1611 with real_moderation_session(super_token) as api: 

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

1613 api.GetModerationState( 

1614 moderation_pb2.GetModerationStateReq( 

1615 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1616 object_id=999999, 

1617 ) 

1618 ) 

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

1620 assert e.value.details() == "Moderation state not found." 

1621 

1622 

1623def test_GetModerationState_unspecified_type(db): 

1624 """Test getting moderation state with unspecified object type""" 

1625 super_user, super_token = generate_user(is_superuser=True) 

1626 

1627 with real_moderation_session(super_token) as api: 

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

1629 api.GetModerationState( 

1630 moderation_pb2.GetModerationStateReq( 

1631 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED, 

1632 object_id=123, 

1633 ) 

1634 ) 

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

1636 assert e.value.details() == "Object type must be specified." 

1637 

1638 

1639def test_ModerateContent_approve(db): 

1640 """Test approving content via unified moderation API""" 

1641 super_user, super_token = generate_user(is_superuser=True) 

1642 user1, token1 = generate_user() 

1643 user2, _ = generate_user() 

1644 

1645 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

1646 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

1647 

1648 # Create a host request using the API (which automatically creates moderation state) 

1649 with requests_session(token1) as api: 

1650 host_request_id = api.CreateHostRequest( 

1651 requests_pb2.CreateHostRequestReq( 

1652 host_user_id=user2.id, 

1653 from_date=today_plus_2, 

1654 to_date=today_plus_3, 

1655 text=valid_request_text(), 

1656 ) 

1657 ).host_request_id 

1658 

1659 # Get the moderation state ID 

1660 state_id = None 

1661 with session_scope() as session: 

1662 host_request = session.execute( 

1663 select(HostRequest).where(HostRequest.conversation_id == host_request_id) 

1664 ).scalar_one() 

1665 state_id = host_request.moderation_state_id 

1666 

1667 with real_moderation_session(super_token) as api: 

1668 res = api.ModerateContent( 

1669 moderation_pb2.ModerateContentReq( 

1670 moderation_state_id=state_id, 

1671 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1672 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1673 reason="Approved by admin", 

1674 ) 

1675 ) 

1676 assert res.moderation_state.moderation_state_id == state_id 

1677 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE 

1678 

1679 # Verify state was updated in database 

1680 with session_scope() as session: 

1681 state = session.get_one(ModerationState, state_id) 

1682 assert state.visibility == ModerationVisibility.visible 

1683 

1684 

1685def test_ModerateContent_not_found(db): 

1686 """Test moderating non-existent content""" 

1687 super_user, super_token = generate_user(is_superuser=True) 

1688 

1689 with real_moderation_session(super_token) as api: 

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

1691 api.ModerateContent( 

1692 moderation_pb2.ModerateContentReq( 

1693 moderation_state_id=999999, 

1694 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1695 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1696 reason="Test", 

1697 ) 

1698 ) 

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

1700 assert e.value.details() == "Moderation state not found." 

1701 

1702 

1703def test_ModerateContent_hide(db): 

1704 """Test hiding content via unified moderation API""" 

1705 super_user, super_token = generate_user(is_superuser=True) 

1706 normal_user, user_token = generate_user() 

1707 host, _ = generate_user() 

1708 

1709 # Create a real host request 

1710 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1711 

1712 with real_moderation_session(super_token) as api: 

1713 res = api.ModerateContent( 

1714 moderation_pb2.ModerateContentReq( 

1715 moderation_state_id=state_id, 

1716 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1717 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

1718 reason="Spam content", 

1719 ) 

1720 ) 

1721 assert res.moderation_state.moderation_state_id == state_id 

1722 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_HIDDEN 

1723 

1724 # Verify state was updated in database 

1725 with session_scope() as session: 

1726 state = session.get_one(ModerationState, state_id) 

1727 assert state.visibility == ModerationVisibility.hidden 

1728 

1729 

1730def test_ModerateContent_shadow(db): 

1731 """Test shadowing content via unified moderation API""" 

1732 super_user, super_token = generate_user(is_superuser=True) 

1733 normal_user, user_token = generate_user() 

1734 host, _ = generate_user() 

1735 

1736 # Create a real host request 

1737 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1738 

1739 with real_moderation_session(super_token) as api: 

1740 res = api.ModerateContent( 

1741 moderation_pb2.ModerateContentReq( 

1742 moderation_state_id=state_id, 

1743 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1744 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

1745 reason="Needs further review", 

1746 ) 

1747 ) 

1748 assert res.moderation_state.moderation_state_id == state_id 

1749 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED 

1750 

1751 # Verify state was updated in database 

1752 with session_scope() as session: 

1753 state = session.get_one(ModerationState, state_id) 

1754 assert state.visibility == ModerationVisibility.shadowed 

1755 

1756 

1757def test_ModerateContent_flag(db): 

1758 """Test opening a flag via the FLAG action""" 

1759 super_user, super_token = generate_user(is_superuser=True) 

1760 user1, token1 = generate_user() 

1761 user2, _ = generate_user() 

1762 

1763 # Create a host request 

1764 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

1765 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

1766 

1767 with requests_session(token1) as api: 

1768 host_request_id = api.CreateHostRequest( 

1769 requests_pb2.CreateHostRequestReq( 

1770 host_user_id=user2.id, 

1771 from_date=today_plus_2, 

1772 to_date=today_plus_3, 

1773 text=valid_request_text(), 

1774 ) 

1775 ).host_request_id 

1776 

1777 # Get the moderation state ID 

1778 with session_scope() as session: 

1779 host_request = session.execute( 

1780 select(HostRequest).where(HostRequest.conversation_id == host_request_id) 

1781 ).scalar_one() 

1782 state_id = host_request.moderation_state_id 

1783 

1784 with real_moderation_session(super_token) as api: 

1785 api.ModerateContent( 

1786 moderation_pb2.ModerateContentReq( 

1787 moderation_state_id=state_id, 

1788 action=moderation_pb2.MODERATION_ACTION_FLAG, 

1789 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

1790 reason="Admin flagged for additional review", 

1791 priority=5, 

1792 ) 

1793 ) 

1794 

1795 # Verify queue item was created in database with the given priority, plus a log row referencing it 

1796 with session_scope() as session: 

1797 queue_item = session.execute( 

1798 select(ModerationQueueItem) 

1799 .where(ModerationQueueItem.moderation_state_id == state_id) 

1800 .where(ModerationQueueItem.trigger == ModerationTrigger.moderator_review) 

1801 ).scalar_one() 

1802 assert queue_item.resolved_by_log_id is None 

1803 assert queue_item.priority == 5 

1804 

1805 flag_log = session.execute( 

1806 select(ModerationLog) 

1807 .where(ModerationLog.moderation_state_id == state_id) 

1808 .where(ModerationLog.action == ModerationAction.flag) 

1809 ).scalar_one() 

1810 assert flag_log.queue_item_id == queue_item.id 

1811 

1812 

1813def test_ModerateContent_flag_requires_trigger(db): 

1814 """FLAG without a trigger is rejected.""" 

1815 super_user, super_token = generate_user(is_superuser=True) 

1816 user1, token1 = generate_user() 

1817 user2, _ = generate_user() 

1818 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1819 

1820 with real_moderation_session(super_token) as api: 

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

1822 api.ModerateContent( 

1823 moderation_pb2.ModerateContentReq( 

1824 moderation_state_id=state_id, 

1825 action=moderation_pb2.MODERATION_ACTION_FLAG, 

1826 reason="no trigger", 

1827 ) 

1828 ) 

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

1830 

1831 

1832def _open_queue_item_id(state_id): 

1833 with session_scope() as session: 

1834 return ( 

1835 session.execute( 

1836 select(ModerationQueueItem) 

1837 .where(ModerationQueueItem.moderation_state_id == state_id) 

1838 .where(ModerationQueueItem.resolved_by_log_id.is_(None)) 

1839 ) 

1840 .scalars() 

1841 .one() 

1842 .id 

1843 ) 

1844 

1845 

1846def test_ModerateContent_set_priority(db): 

1847 """SET_PRIORITY changes a flag's priority and logs it, without touching visibility.""" 

1848 moderator, mod_token = generate_user(is_superuser=True) 

1849 user1, token1 = generate_user() 

1850 user2, _ = generate_user() 

1851 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1852 queue_item_id = _open_queue_item_id(state_id) 

1853 

1854 with real_moderation_session(mod_token) as api: 

1855 api.ModerateContent( 

1856 moderation_pb2.ModerateContentReq( 

1857 moderation_state_id=state_id, 

1858 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY, 

1859 queue_item_id=queue_item_id, 

1860 priority=10, 

1861 reason="bump", 

1862 ) 

1863 ) 

1864 

1865 with session_scope() as session: 

1866 queue_item = session.get_one(ModerationQueueItem, queue_item_id) 

1867 assert queue_item.priority == 10 

1868 assert queue_item.resolved_by_log_id is None # not resolved 

1869 # State stays shadowed (visibility untouched) 

1870 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.shadowed 

1871 

1872 log = session.execute( 

1873 select(ModerationLog) 

1874 .where(ModerationLog.moderation_state_id == state_id) 

1875 .where(ModerationLog.action == ModerationAction.set_priority) 

1876 ).scalar_one() 

1877 assert log.new_priority == 10 

1878 assert log.queue_item_id == queue_item_id 

1879 

1880 # GetModerationQueue surfaces the new priority 

1881 with real_moderation_session(mod_token) as api: 

1882 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True)) 

1883 item = next(i for i in res.queue_items if i.queue_item_id == queue_item_id) 

1884 assert item.priority == 10 

1885 

1886 

1887def test_ModerateContent_unflag(db): 

1888 """UNFLAG resolves a single named flag and does not change visibility.""" 

1889 moderator, mod_token = generate_user(is_superuser=True) 

1890 user1, token1 = generate_user() 

1891 user2, _ = generate_user() 

1892 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1893 queue_item_id = _open_queue_item_id(state_id) 

1894 

1895 with real_moderation_session(mod_token) as api: 

1896 api.ModerateContent( 

1897 moderation_pb2.ModerateContentReq( 

1898 moderation_state_id=state_id, 

1899 action=moderation_pb2.MODERATION_ACTION_UNFLAG, 

1900 queue_item_id=queue_item_id, 

1901 reason="dismissed", 

1902 ) 

1903 ) 

1904 

1905 with session_scope() as session: 

1906 queue_item = session.get_one(ModerationQueueItem, queue_item_id) 

1907 assert queue_item.resolved_by_log_id is not None 

1908 # Visibility unchanged (still shadowed from creation) 

1909 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.shadowed 

1910 log = session.get_one(ModerationLog, queue_item.resolved_by_log_id) 

1911 assert log.action == ModerationAction.unflag 

1912 assert log.queue_item_id == queue_item_id 

1913 

1914 

1915def test_ModerateContent_unflag_requires_queue_item(db): 

1916 """UNFLAG without a queue_item_id is rejected.""" 

1917 moderator, mod_token = generate_user(is_superuser=True) 

1918 user1, token1 = generate_user() 

1919 user2, _ = generate_user() 

1920 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1921 

1922 with real_moderation_session(mod_token) as api: 

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

1924 api.ModerateContent( 

1925 moderation_pb2.ModerateContentReq( 

1926 moderation_state_id=state_id, 

1927 action=moderation_pb2.MODERATION_ACTION_UNFLAG, 

1928 reason="no target", 

1929 ) 

1930 ) 

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

1932 

1933 

1934def test_ModerateContent_approve_without_clear_flags_leaves_flag_open(db): 

1935 """APPROVE without clear_flags changes visibility but leaves open flags in the queue.""" 

1936 moderator, mod_token = generate_user(is_superuser=True) 

1937 user1, token1 = generate_user() 

1938 user2, _ = generate_user() 

1939 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1940 queue_item_id = _open_queue_item_id(state_id) 

1941 

1942 with real_moderation_session(mod_token) as api: 

1943 api.ModerateContent( 

1944 moderation_pb2.ModerateContentReq( 

1945 moderation_state_id=state_id, 

1946 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1947 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1948 reason="visible but still under review", 

1949 ) 

1950 ) 

1951 

1952 with session_scope() as session: 

1953 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.visible 

1954 assert session.get_one(ModerationQueueItem, queue_item_id).resolved_by_log_id is None 

1955 

1956 

1957def test_ModerateContent_flag_supersede(db): 

1958 """FLAG with supersede_queue_item_id resolves the named flag as the new one opens.""" 

1959 moderator, mod_token = generate_user(is_superuser=True) 

1960 user1, token1 = generate_user() 

1961 user2, _ = generate_user() 

1962 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1963 initial_item_id = _open_queue_item_id(state_id) 

1964 

1965 with real_moderation_session(mod_token) as api: 

1966 api.ModerateContent( 

1967 moderation_pb2.ModerateContentReq( 

1968 moderation_state_id=state_id, 

1969 action=moderation_pb2.MODERATION_ACTION_FLAG, 

1970 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG, 

1971 priority=3, 

1972 supersede_queue_item_id=initial_item_id, 

1973 reason="machine re-flag", 

1974 ) 

1975 ) 

1976 

1977 with session_scope() as session: 

1978 # Old initial_review flag is resolved 

1979 assert session.get_one(ModerationQueueItem, initial_item_id).resolved_by_log_id is not None 

1980 # Exactly one open flag remains: the new machine_flag at priority 3 

1981 open_items = ( 

1982 session.execute( 

1983 select(ModerationQueueItem) 

1984 .where(ModerationQueueItem.moderation_state_id == state_id) 

1985 .where(ModerationQueueItem.resolved_by_log_id.is_(None)) 

1986 ) 

1987 .scalars() 

1988 .all() 

1989 ) 

1990 assert len(open_items) == 1 

1991 assert open_items[0].trigger == ModerationTrigger.machine_flag 

1992 assert open_items[0].priority == 3 

1993 

1994 

1995def test_GetModerationQueue_filter_by_priority(db): 

1996 """priority_min / priority_max filter the queue by priority range.""" 

1997 moderator, mod_token = generate_user(is_superuser=True) 

1998 user1, token1 = generate_user() 

1999 user2, _ = generate_user() 

2000 state_id = create_test_host_request_with_moderation(token1, user2.id) 

2001 queue_item_id = _open_queue_item_id(state_id) 

2002 

2003 with real_moderation_session(mod_token) as api: 

2004 api.ModerateContent( 

2005 moderation_pb2.ModerateContentReq( 

2006 moderation_state_id=state_id, 

2007 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY, 

2008 queue_item_id=queue_item_id, 

2009 priority=7, 

2010 reason="raise", 

2011 ) 

2012 ) 

2013 

2014 # priority_min excludes it below the threshold, includes at/above 

2015 assert len(api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_min=8)).queue_items) == 0 

2016 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_min=7)) 

2017 assert [i.queue_item_id for i in res.queue_items] == [queue_item_id] 

2018 

2019 # priority_max excludes it above the threshold 

2020 assert len(api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_max=6)).queue_items) == 0 

2021 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_max=7)) 

2022 assert [i.queue_item_id for i in res.queue_items] == [queue_item_id] 

2023 

2024 

2025# ============================================================================ 

2026# Tests for group chat moderation 

2027# ============================================================================ 

2028 

2029 

2030def test_group_chat_created_with_moderation_state(db): 

2031 """Test that group chats are created with moderation state""" 

2032 user1, token1 = generate_user() 

2033 user2, _ = generate_user() 

2034 make_friends(user1, user2) 

2035 

2036 with conversations_session(token1) as api: 

2037 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])) 

2038 group_chat_id = res.group_chat_id 

2039 

2040 # Verify moderation state was created 

2041 with session_scope() as session: 

2042 group_chat = session.execute(select(GroupChat).where(GroupChat.conversation_id == group_chat_id)).scalar_one() 

2043 

2044 assert group_chat.moderation_state.object_type == ModerationObjectType.group_chat 

2045 assert group_chat.moderation_state.object_id == group_chat_id 

2046 # Group chats start as SHADOWED 

2047 assert group_chat.moderation_state.visibility == ModerationVisibility.shadowed 

2048 

2049 # A moderation queue item should have been created 

2050 queue_item = ( 

2051 session.execute( 

2052 select(ModerationQueueItem).where( 

2053 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id 

2054 ) 

2055 ) 

2056 .scalars() 

2057 .first() 

2058 ) 

2059 assert queue_item is not None 

2060 assert queue_item.trigger == ModerationTrigger.initial_review 

2061 

2062 

2063def test_group_chat_GetModerationState(db): 

2064 """Test GetModerationState API for group chats""" 

2065 user1, token1 = generate_user() 

2066 user2, _ = generate_user() 

2067 moderator, mod_token = generate_user(is_superuser=True) 

2068 make_friends(user1, user2) 

2069 

2070 with conversations_session(token1) as api: 

2071 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])) 

2072 group_chat_id = res.group_chat_id 

2073 

2074 # Moderator can look up the moderation state 

2075 with real_moderation_session(mod_token) as api: 

2076 res = api.GetModerationState( 

2077 moderation_pb2.GetModerationStateReq( 

2078 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

2079 object_id=group_chat_id, 

2080 ) 

2081 ) 

2082 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT 

2083 assert res.moderation_state.object_id == group_chat_id 

2084 # Starts as SHADOWED 

2085 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED 

2086 

2087 

2088def test_group_chat_moderation_hide(db): 

2089 """Test that a moderator can hide a group chat and participants can no longer see it""" 

2090 user1, token1 = generate_user() 

2091 user2, token2 = generate_user() 

2092 moderator, mod_token = generate_user(is_superuser=True) 

2093 make_friends(user1, user2) 

2094 

2095 with conversations_session(token1) as api: 

2096 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])) 

2097 group_chat_id = res.group_chat_id 

2098 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!")) 

2099 

2100 # First approve the group chat so both users can see it 

2101 with real_moderation_session(mod_token) as api: 

2102 state_res = api.GetModerationState( 

2103 moderation_pb2.GetModerationStateReq( 

2104 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

2105 object_id=group_chat_id, 

2106 ) 

2107 ) 

2108 api.ModerateContent( 

2109 moderation_pb2.ModerateContentReq( 

2110 moderation_state_id=state_res.moderation_state.moderation_state_id, 

2111 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

2112 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

2113 reason="Approved", 

2114 ) 

2115 ) 

2116 

2117 # Both users can see the chat now 

2118 with conversations_session(token1) as api: 

2119 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq()) 

2120 assert len(res.group_chats) == 1 

2121 

2122 with conversations_session(token2) as api: 

2123 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq()) 

2124 assert len(res.group_chats) == 1 

2125 

2126 # Moderator hides the group chat 

2127 with real_moderation_session(mod_token) as api: 

2128 state_res = api.GetModerationState( 

2129 moderation_pb2.GetModerationStateReq( 

2130 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

2131 object_id=group_chat_id, 

2132 ) 

2133 ) 

2134 api.ModerateContent( 

2135 moderation_pb2.ModerateContentReq( 

2136 moderation_state_id=state_res.moderation_state.moderation_state_id, 

2137 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2138 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2139 reason="Inappropriate content", 

2140 ) 

2141 ) 

2142 

2143 # Neither user can see the chat now 

2144 with conversations_session(token1) as api: 

2145 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq()) 

2146 assert len(res.group_chats) == 0 

2147 

2148 with conversations_session(token2) as api: 

2149 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq()) 

2150 assert len(res.group_chats) == 0 

2151 

2152 # Trying to get messages returns empty (chat is hidden so no messages visible) 

2153 with conversations_session(token1) as api: 

2154 res = api.GetGroupChatMessages(conversations_pb2.GetGroupChatMessagesReq(group_chat_id=group_chat_id)) 

2155 assert len(res.messages) == 0 

2156 

2157 

2158def test_group_chat_moderation_shadow(db): 

2159 """Test that shadowing a group chat hides it from non-creator participants""" 

2160 user1, token1 = generate_user() # Creator 

2161 user2, token2 = generate_user() # Participant 

2162 moderator, mod_token = generate_user(is_superuser=True) 

2163 make_friends(user1, user2) 

2164 

2165 with conversations_session(token1) as api: 

2166 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id])) 

2167 group_chat_id = res.group_chat_id 

2168 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!")) 

2169 

2170 # Moderator shadows the group chat 

2171 with real_moderation_session(mod_token) as api: 

2172 state_res = api.GetModerationState( 

2173 moderation_pb2.GetModerationStateReq( 

2174 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

2175 object_id=group_chat_id, 

2176 ) 

2177 ) 

2178 api.ModerateContent( 

2179 moderation_pb2.ModerateContentReq( 

2180 moderation_state_id=state_res.moderation_state.moderation_state_id, 

2181 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2182 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

2183 reason="Needs review", 

2184 ) 

2185 ) 

2186 

2187 # Creator can see SHADOWED content in list operations 

2188 with conversations_session(token1) as api: 

2189 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq()) 

2190 assert len(res.group_chats) == 1 

2191 assert res.group_chats[0].group_chat_id == group_chat_id 

2192 

2193 # But non-creator participant cannot see it in lists 

2194 with conversations_session(token2) as api: 

2195 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq()) 

2196 assert len(res.group_chats) == 0 

2197 

2198 # Creator can also access it directly via GetGroupChat 

2199 with conversations_session(token1) as api: 

2200 res = api.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id)) 

2201 assert res.group_chat_id == group_chat_id 

2202 

2203 

2204# ============================================================================ 

2205# Tests for auto-approval background job 

2206# ============================================================================ 

2207 

2208 

2209def test_auto_approve_moderation_queue_disabled_when_zero(db, email_collector: EmailCollector): 

2210 """Test that auto-approval is disabled when deadline is 0""" 

2211 moderator, mod_token = generate_user(is_superuser=True) 

2212 user1, token1 = generate_user() 

2213 user2, token2 = generate_user() 

2214 

2215 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

2216 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

2217 

2218 # Create a host request 

2219 with requests_session(token1) as api: 

2220 host_request_id = api.CreateHostRequest( 

2221 requests_pb2.CreateHostRequestReq( 

2222 host_user_id=user2.id, 

2223 from_date=today_plus_2, 

2224 to_date=today_plus_3, 

2225 text=valid_request_text(), 

2226 ) 

2227 ).host_request_id 

2228 

2229 # No email should have been sent (request is shadowed) 

2230 assert email_collector.count_for_recipient(user2.email) == 0 

2231 

2232 # Ensure deadline is 0 (disabled) 

2233 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 0 

2234 

2235 # Run the job 

2236 auto_approve_moderation_queue(empty_pb2.Empty()) 

2237 

2238 # Surfer (author) can see the request via API 

2239 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

2240 assert res.host_request_id == host_request_id 

2241 

2242 # Author can see their SHADOWED request in their sent list 

2243 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True)) 

2244 assert len(res.host_requests) == 1 

2245 assert res.host_requests[0].host_request_id == host_request_id 

2246 

2247 # Host cannot see the request (it's shadowed from them) 

2248 with requests_session(token2) as api: 

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

2250 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

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

2252 

2253 # Host doesn't see it in their received list either 

2254 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True)) 

2255 assert len(res.host_requests) == 0 

2256 

2257 # Moderator can still see the item in the moderation queue 

2258 with real_moderation_session(mod_token) as api: 

2259 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True)) 

2260 assert len(res.queue_items) == 1 

2261 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW 

2262 

2263 # Moderator can check the state is still SHADOWED 

2264 state_res = api.GetModerationState( 

2265 moderation_pb2.GetModerationStateReq( 

2266 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2267 object_id=host_request_id, 

2268 ) 

2269 ) 

2270 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED 

2271 

2272 

2273def test_auto_approve_moderation_queue_approves_old_items( 

2274 db, email_collector: EmailCollector, push_collector: PushCollector 

2275): 

2276 """Test that auto-approval approves items older than the deadline""" 

2277 moderator, mod_token = generate_user(is_superuser=True) 

2278 user1, token1 = generate_user() 

2279 user2, token2 = generate_user() 

2280 

2281 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

2282 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

2283 

2284 # Create a host request 

2285 with requests_session(token1) as api: 

2286 host_request_id = api.CreateHostRequest( 

2287 requests_pb2.CreateHostRequestReq( 

2288 host_user_id=user2.id, 

2289 from_date=today_plus_2, 

2290 to_date=today_plus_3, 

2291 text=valid_request_text("Test request for auto-approval"), 

2292 ) 

2293 ).host_request_id 

2294 

2295 # No email sent initially (shadowed) 

2296 assert email_collector.count_for_recipient(user2.email) == 0 

2297 

2298 # Host cannot see the request yet 

2299 with requests_session(token2) as api: 

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

2301 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

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

2303 

2304 # Make the queue item appear old by backdating its time_created 

2305 with session_scope() as session: 

2306 host_request = session.execute( 

2307 select(HostRequest).where(HostRequest.conversation_id == host_request_id) 

2308 ).scalar_one() 

2309 queue_item = session.execute( 

2310 select(ModerationQueueItem) 

2311 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id) 

2312 .where(ModerationQueueItem.resolved_by_log_id.is_(None)) 

2313 ).scalar_one() 

2314 # Backdate the queue item by 2 minutes 

2315 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=2) 

2316 

2317 # Set deadline to 60 seconds (items older than 60 seconds will be auto-approved) 

2318 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60 

2319 config.MODERATION_BOT_USER_ID = moderator.id 

2320 

2321 # Run the job 

2322 auto_approve_moderation_queue(empty_pb2.Empty()) 

2323 

2324 # Now host can see the request via API 

2325 with requests_session(token2) as api: 

2326 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

2327 assert res.host_request_id == host_request_id 

2328 assert res.latest_message.text.text == valid_request_text("Test request for auto-approval") 

2329 

2330 # Host sees it in their received list 

2331 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True)) 

2332 assert len(res.host_requests) == 1 

2333 assert res.host_requests[0].host_request_id == host_request_id 

2334 

2335 # Surfer sees it in their sent list 

2336 with requests_session(token1) as api: 

2337 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True)) 

2338 assert len(res.host_requests) == 1 

2339 assert res.host_requests[0].host_request_id == host_request_id 

2340 

2341 with real_moderation_session(mod_token) as api: 

2342 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True)) 

2343 assert len(res.queue_items) == 1 

2344 flag_item = res.queue_items[0] 

2345 assert flag_item.trigger == moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG 

2346 assert flag_item.priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY 

2347 

2348 state_res = api.GetModerationState( 

2349 moderation_pb2.GetModerationStateReq( 

2350 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2351 object_id=host_request_id, 

2352 ) 

2353 ) 

2354 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE 

2355 

2356 log_res = api.GetModerationLog( 

2357 moderation_pb2.GetModerationLogReq(moderation_state_id=state_res.moderation_state.moderation_state_id) 

2358 ) 

2359 approve_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_APPROVE] 

2360 assert len(approve_entries) == 1 

2361 assert "Auto-approved" in approve_entries[0].reason 

2362 assert "60 seconds" in approve_entries[0].reason 

2363 assert approve_entries[0].moderator_user_id == moderator.id 

2364 

2365 flag_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_FLAG] 

2366 assert len(flag_entries) == 1 

2367 assert flag_entries[0].moderator_user_id == moderator.id 

2368 

2369 

2370def test_auto_approve_does_not_approve_recent_items(db, email_collector: EmailCollector): 

2371 """Test that auto-approval does not approve items that are newer than the deadline""" 

2372 moderator, mod_token = generate_user(is_superuser=True) 

2373 user1, token1 = generate_user() 

2374 user2, token2 = generate_user() 

2375 

2376 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

2377 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

2378 

2379 # Create a host request 

2380 with requests_session(token1) as api: 

2381 host_request_id = api.CreateHostRequest( 

2382 requests_pb2.CreateHostRequestReq( 

2383 host_user_id=user2.id, 

2384 from_date=today_plus_2, 

2385 to_date=today_plus_3, 

2386 text=valid_request_text(), 

2387 ) 

2388 ).host_request_id 

2389 

2390 # No email sent (shadowed) 

2391 assert email_collector.count_for_recipient(user2.email) == 0 

2392 

2393 # Set deadline to 1 hour (items older than 1 hour will be auto-approved) 

2394 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 3600 

2395 config.MODERATION_BOT_USER_ID = moderator.id 

2396 

2397 # Run the job - the item was just created, so it shouldn't be approved 

2398 auto_approve_moderation_queue(empty_pb2.Empty()) 

2399 

2400 # Still no email sent 

2401 assert email_collector.count_for_recipient(user2.email) == 0 

2402 

2403 # Host still cannot see the request 

2404 with requests_session(token2) as api: 

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

2406 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

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

2408 

2409 # Not in host's received list 

2410 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True)) 

2411 assert len(res.host_requests) == 0 

2412 

2413 # Moderator sees it still in queue unresolved 

2414 with real_moderation_session(mod_token) as api: 

2415 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True)) 

2416 assert len(res.queue_items) == 1 

2417 

2418 # State is still SHADOWED 

2419 state_res = api.GetModerationState( 

2420 moderation_pb2.GetModerationStateReq( 

2421 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2422 object_id=host_request_id, 

2423 ) 

2424 ) 

2425 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED 

2426 

2427 

2428def test_auto_approve_does_not_approve_already_approved(db): 

2429 """Test that auto-approval does not re-approve already visible content""" 

2430 moderator, mod_token = generate_user(is_superuser=True) 

2431 user1, token1 = generate_user() 

2432 user2, token2 = generate_user() 

2433 

2434 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

2435 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

2436 

2437 # Create a host request 

2438 with requests_session(token1) as api: 

2439 host_request_id = api.CreateHostRequest( 

2440 requests_pb2.CreateHostRequestReq( 

2441 host_user_id=user2.id, 

2442 from_date=today_plus_2, 

2443 to_date=today_plus_3, 

2444 text=valid_request_text(), 

2445 ) 

2446 ).host_request_id 

2447 

2448 # Moderator approves it manually 

2449 with real_moderation_session(mod_token) as api: 

2450 state_res = api.GetModerationState( 

2451 moderation_pb2.GetModerationStateReq( 

2452 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2453 object_id=host_request_id, 

2454 ) 

2455 ) 

2456 state_id = state_res.moderation_state.moderation_state_id 

2457 

2458 api.ModerateContent( 

2459 moderation_pb2.ModerateContentReq( 

2460 moderation_state_id=state_id, 

2461 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

2462 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

2463 reason="Approved by moderator", 

2464 clear_flags=True, 

2465 ) 

2466 ) 

2467 

2468 # Host can now see it 

2469 with requests_session(token2) as api: 

2470 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

2471 assert res.host_request_id == host_request_id 

2472 

2473 # Get log count before auto-approval 

2474 with real_moderation_session(mod_token) as api: 

2475 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id)) 

2476 log_count_before = len(log_res_before.log_entries) 

2477 

2478 # Set deadline to 1 second 

2479 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1 

2480 config.MODERATION_BOT_USER_ID = moderator.id 

2481 

2482 # Run the job 

2483 auto_approve_moderation_queue(empty_pb2.Empty()) 

2484 

2485 # No new log entries should be created (already approved, queue item resolved) 

2486 with real_moderation_session(mod_token) as api: 

2487 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id)) 

2488 assert len(log_res_after.log_entries) == log_count_before 

2489 

2490 # Queue should be empty (item was resolved when moderator approved) 

2491 queue_res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True)) 

2492 assert len(queue_res.queue_items) == 0 

2493 

2494 

2495def test_auto_approve_does_not_approve_moderator_shadowed_items(db): 

2496 """Test that auto-approval does not approve items that were explicitly shadowed by a moderator""" 

2497 moderator, mod_token = generate_user(is_superuser=True) 

2498 user1, token1 = generate_user() 

2499 user2, token2 = generate_user() 

2500 

2501 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

2502 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

2503 

2504 # Create a host request 

2505 with requests_session(token1) as api: 

2506 host_request_id = api.CreateHostRequest( 

2507 requests_pb2.CreateHostRequestReq( 

2508 host_user_id=user2.id, 

2509 from_date=today_plus_2, 

2510 to_date=today_plus_3, 

2511 text=valid_request_text(), 

2512 ) 

2513 ).host_request_id 

2514 

2515 # Moderator explicitly shadows the content (keeping it shadowed but resolving the queue item) 

2516 with real_moderation_session(mod_token) as api: 

2517 state_res = api.GetModerationState( 

2518 moderation_pb2.GetModerationStateReq( 

2519 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2520 object_id=host_request_id, 

2521 ) 

2522 ) 

2523 state_id = state_res.moderation_state.moderation_state_id 

2524 

2525 # Set to SHADOWED explicitly with clear_flags - this resolves the INITIAL_REVIEW queue item 

2526 api.ModerateContent( 

2527 moderation_pb2.ModerateContentReq( 

2528 moderation_state_id=state_id, 

2529 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2530 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

2531 reason="Keeping shadowed for review", 

2532 clear_flags=True, 

2533 ) 

2534 ) 

2535 

2536 # Backdate to ensure it would be old enough for auto-approval 

2537 with session_scope() as session: 

2538 queue_item = session.execute( 

2539 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id) 

2540 ).scalar_one() 

2541 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10) 

2542 

2543 # Set deadline to 1 second 

2544 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1 

2545 config.MODERATION_BOT_USER_ID = moderator.id 

2546 

2547 # Get log count before 

2548 with real_moderation_session(mod_token) as api: 

2549 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id)) 

2550 log_count_before = len(log_res_before.log_entries) 

2551 

2552 # Run the job 

2553 auto_approve_moderation_queue(empty_pb2.Empty()) 

2554 

2555 # No new log entries - the queue item was resolved when moderator shadowed it 

2556 with real_moderation_session(mod_token) as api: 

2557 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id)) 

2558 assert len(log_res_after.log_entries) == log_count_before 

2559 

2560 # State should still be SHADOWED (not auto-approved to VISIBLE) 

2561 state_res = api.GetModerationState( 

2562 moderation_pb2.GetModerationStateReq( 

2563 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2564 object_id=host_request_id, 

2565 ) 

2566 ) 

2567 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED 

2568 

2569 # Host still cannot see the request 

2570 with requests_session(token2) as api: 

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

2572 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

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

2574 

2575 

2576def test_auto_approve_skips_shadowed_user_authored_items(db): 

2577 """Auto-approval must not promote content authored by a currently-shadowed user.""" 

2578 moderator, mod_token = generate_user(is_superuser=True) 

2579 surfer, surfer_token = generate_user() 

2580 host, host_token = generate_user() 

2581 

2582 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

2583 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

2584 

2585 with requests_session(surfer_token) as api: 

2586 host_request_id = api.CreateHostRequest( 

2587 requests_pb2.CreateHostRequestReq( 

2588 host_user_id=host.id, 

2589 from_date=today_plus_2, 

2590 to_date=today_plus_3, 

2591 text=valid_request_text(), 

2592 ) 

2593 ).host_request_id 

2594 

2595 # Shadow the surfer after the host request was created 

2596 with session_scope() as session: 

2597 session.execute(select(User).where(User.id == surfer.id)).scalar_one().shadowed_at = now() 

2598 

2599 # Backdate the queue item to make it eligible for auto-approval 

2600 with session_scope() as session: 

2601 host_request = session.execute( 

2602 select(HostRequest).where(HostRequest.conversation_id == host_request_id) 

2603 ).scalar_one() 

2604 queue_item = session.execute( 

2605 select(ModerationQueueItem) 

2606 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id) 

2607 .where(ModerationQueueItem.resolved_by_log_id.is_(None)) 

2608 ).scalar_one() 

2609 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10) 

2610 

2611 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60 

2612 config.MODERATION_BOT_USER_ID = moderator.id 

2613 

2614 auto_approve_moderation_queue(empty_pb2.Empty()) 

2615 

2616 # State should remain SHADOWED — the auto-approve job must skip shadowed-user content 

2617 with real_moderation_session(mod_token) as api: 

2618 state_res = api.GetModerationState( 

2619 moderation_pb2.GetModerationStateReq( 

2620 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2621 object_id=host_request_id, 

2622 ) 

2623 ) 

2624 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED 

2625 

2626 # Host still cannot see the request 

2627 with requests_session(host_token) as api: 

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

2629 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

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

2631 

2632 # But the (shadowed) author can still see their own request 

2633 with requests_session(surfer_token) as api: 

2634 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id)) 

2635 assert res.host_request_id == host_request_id 

2636 

2637 

2638def test_auto_approve_preserves_other_open_flags(db): 

2639 """Auto-approval supersedes only the INITIAL_REVIEW item; other open flags survive and a high-prio flag is added.""" 

2640 moderator, mod_token = generate_user(is_superuser=True) 

2641 surfer, surfer_token = generate_user() 

2642 host, _ = generate_user() 

2643 

2644 state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

2645 

2646 with real_moderation_session(mod_token) as api: 

2647 api.ModerateContent( 

2648 moderation_pb2.ModerateContentReq( 

2649 moderation_state_id=state_id, 

2650 action=moderation_pb2.MODERATION_ACTION_FLAG, 

2651 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

2652 priority=5, 

2653 reason="user reported this", 

2654 ) 

2655 ) 

2656 

2657 with session_scope() as session: 

2658 initial_item = session.execute( 

2659 select(ModerationQueueItem) 

2660 .where(ModerationQueueItem.moderation_state_id == state_id) 

2661 .where(ModerationQueueItem.trigger == ModerationTrigger.initial_review) 

2662 ).scalar_one() 

2663 initial_item_id = initial_item.id 

2664 initial_item.time_created = datetime.now(initial_item.time_created.tzinfo) - timedelta(minutes=10) 

2665 

2666 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60 

2667 config.MODERATION_BOT_USER_ID = moderator.id 

2668 

2669 auto_approve_moderation_queue(empty_pb2.Empty()) 

2670 

2671 with session_scope() as session: 

2672 assert ( 

2673 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.id == initial_item_id)) 

2674 .scalar_one() 

2675 .resolved_by_log_id 

2676 is not None 

2677 ) 

2678 assert ( 

2679 session.execute(select(ModerationState).where(ModerationState.id == state_id)).scalar_one().visibility 

2680 == ModerationVisibility.visible 

2681 ) 

2682 

2683 open_items = ( 

2684 session.execute( 

2685 select(ModerationQueueItem) 

2686 .where(ModerationQueueItem.moderation_state_id == state_id) 

2687 .where(ModerationQueueItem.resolved_by_log_id.is_(None)) 

2688 ) 

2689 .scalars() 

2690 .all() 

2691 ) 

2692 open_by_trigger = {item.trigger: item for item in open_items} 

2693 assert open_by_trigger.keys() == {ModerationTrigger.user_flag, ModerationTrigger.machine_flag} 

2694 assert open_by_trigger[ModerationTrigger.user_flag].priority == 5 

2695 assert open_by_trigger[ModerationTrigger.machine_flag].priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY 

2696 

2697 

2698def _backdate_initial_review(state_id, delta): 

2699 with session_scope() as session: 

2700 queue_item = session.execute( 

2701 select(ModerationQueueItem) 

2702 .where(ModerationQueueItem.moderation_state_id == state_id) 

2703 .where(ModerationQueueItem.trigger == ModerationTrigger.initial_review) 

2704 ).scalar_one() 

2705 queue_item.time_created -= delta 

2706 

2707 

2708def _visibility(state_id): 

2709 with session_scope() as session: 

2710 return session.execute(select(ModerationState.visibility).where(ModerationState.id == state_id)).scalar_one() 

2711 

2712 

2713def _set_moderation_auto_approve_deadline(token, user, seconds): 

2714 with real_admin_session(token) as api: 

2715 api.SetModerationAutoApproveDeadline( 

2716 admin_pb2.SetModerationAutoApproveDeadlineReq( 

2717 user=user.username, 

2718 auto_approve_seconds=wrappers_pb2.Int64Value(value=seconds) if seconds is not None else None, 

2719 ) 

2720 ) 

2721 

2722 

2723def test_auto_approve_uses_author_deadline(db): 

2724 """Lowering an author's deadline applies to their already pending content without touching other authors""" 

2725 moderator, mod_token = generate_user(is_superuser=True) 

2726 surfer, surfer_token = generate_user() 

2727 other_surfer, other_surfer_token = generate_user() 

2728 host, _ = generate_user() 

2729 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 3600 

2730 config.MODERATION_BOT_USER_ID = moderator.id 

2731 

2732 state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

2733 other_state_id = create_test_host_request_with_moderation(other_surfer_token, host.id) 

2734 _backdate_initial_review(state_id, timedelta(minutes=10)) 

2735 _backdate_initial_review(other_state_id, timedelta(minutes=10)) 

2736 

2737 auto_approve_moderation_queue(empty_pb2.Empty()) 

2738 assert _visibility(state_id) == ModerationVisibility.shadowed 

2739 assert _visibility(other_state_id) == ModerationVisibility.shadowed 

2740 

2741 _set_moderation_auto_approve_deadline(mod_token, surfer, 300) 

2742 

2743 auto_approve_moderation_queue(empty_pb2.Empty()) 

2744 assert _visibility(state_id) == ModerationVisibility.visible 

2745 assert _visibility(other_state_id) == ModerationVisibility.shadowed 

2746 

2747 with real_moderation_session(mod_token) as api: 

2748 log_res = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id)) 

2749 approve_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_APPROVE] 

2750 assert len(approve_entries) == 1 

2751 assert "300 seconds" in approve_entries[0].reason 

2752 

2753 

2754@pytest.mark.parametrize("seconds", [0, 86400]) 

2755def test_auto_approve_author_deadline_holds_content(db, seconds): 

2756 """A long or zero (never) author deadline holds content past the default without tripping the consistency check""" 

2757 moderator, mod_token = generate_user(is_superuser=True) 

2758 surfer, surfer_token = generate_user() 

2759 host, _ = generate_user() 

2760 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60 

2761 config.MODERATION_BOT_USER_ID = moderator.id 

2762 

2763 _set_moderation_auto_approve_deadline(mod_token, surfer, seconds) 

2764 state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

2765 _backdate_initial_review(state_id, timedelta(minutes=10)) 

2766 

2767 auto_approve_moderation_queue(empty_pb2.Empty()) 

2768 assert _visibility(state_id) == ModerationVisibility.shadowed 

2769 check_database_consistency(empty_pb2.Empty()) 

2770 

2771 _set_moderation_auto_approve_deadline(mod_token, surfer, None) 

2772 with pytest.raises(DatabaseInconsistencyError, match="exceeding auto-approve deadline"): 

2773 check_database_consistency(empty_pb2.Empty()) 

2774 

2775 auto_approve_moderation_queue(empty_pb2.Empty()) 

2776 assert _visibility(state_id) == ModerationVisibility.visible 

2777 check_database_consistency(empty_pb2.Empty()) 

2778 

2779 

2780def test_auto_approve_does_not_unhide_hidden_content(db): 

2781 """Content a moderator hid without resolving its initial review is left hidden""" 

2782 moderator, mod_token = generate_user(is_superuser=True) 

2783 surfer, surfer_token = generate_user() 

2784 host, _ = generate_user() 

2785 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60 

2786 config.MODERATION_BOT_USER_ID = moderator.id 

2787 

2788 state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

2789 with real_moderation_session(mod_token) as api: 

2790 api.ModerateContent( 

2791 moderation_pb2.ModerateContentReq( 

2792 moderation_state_id=state_id, 

2793 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2794 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2795 reason="Hidden", 

2796 clear_flags=False, 

2797 ) 

2798 ) 

2799 _backdate_initial_review(state_id, timedelta(minutes=10)) 

2800 

2801 auto_approve_moderation_queue(empty_pb2.Empty()) 

2802 

2803 assert _visibility(state_id) == ModerationVisibility.hidden 

2804 with session_scope() as session: 

2805 initial_item = session.execute( 

2806 select(ModerationQueueItem) 

2807 .where(ModerationQueueItem.moderation_state_id == state_id) 

2808 .where(ModerationQueueItem.trigger == ModerationTrigger.initial_review) 

2809 ).scalar_one() 

2810 assert initial_item.resolved_by_log_id is None 

2811 

2812 

2813# ============================================================================ 

2814# Notification Suppression Tests 

2815# ============================================================================ 

2816 

2817 

2818def test_host_request_message_notifications_suppressed_before_approval( 

2819 db, email_collector: EmailCollector, push_collector: PushCollector, moderator 

2820): 

2821 """ 

2822 Test that notifications are NOT sent for messages in host requests 

2823 that haven't been approved yet. 

2824 """ 

2825 host, host_token = generate_user(complete_profile=True) 

2826 surfer, surfer_token = generate_user(complete_profile=True) 

2827 

2828 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

2829 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

2830 

2831 # Create host request (it starts in SHADOWED state) 

2832 with requests_session(surfer_token) as api: 

2833 hr_id = api.CreateHostRequest( 

2834 requests_pb2.CreateHostRequestReq( 

2835 host_user_id=host.id, 

2836 from_date=today_plus_2, 

2837 to_date=today_plus_3, 

2838 text=valid_request_text("Initial request message"), 

2839 ) 

2840 ).host_request_id 

2841 

2842 # No notifications should have been sent to the host (request is SHADOWED) 

2843 assert email_collector.count_for_recipient(host.email) == 0 

2844 assert push_collector.count_for_user(host.id) == 0 

2845 

2846 # Send additional messages BEFORE approval - should NOT generate notifications 

2847 with requests_session(surfer_token) as api: 

2848 api.SendHostRequestMessage( 

2849 requests_pb2.SendHostRequestMessageReq( 

2850 host_request_id=hr_id, 

2851 text="Follow-up message 1", 

2852 ) 

2853 ) 

2854 api.SendHostRequestMessage( 

2855 requests_pb2.SendHostRequestMessageReq( 

2856 host_request_id=hr_id, 

2857 text="Follow-up message 2", 

2858 ) 

2859 ) 

2860 

2861 # Host should STILL have no notifications (messages sent while SHADOWED) 

2862 assert email_collector.count_for_recipient(host.email) == 0 

2863 assert push_collector.count_for_user(host.id) == 0 

2864 

2865 # Now approve the request 

2866 moderator.approve_host_request(hr_id) 

2867 email_collector.pop_for_recipient(host.email) 

2868 

2869 # Host should now have 3 notifications (all deferred notifications are delivered on approval): 

2870 # 1. host_request:create (the initial request) 

2871 # 2. host_request:message (Follow-up message 1) 

2872 # 3. host_request:message (Follow-up message 2) 

2873 assert push_collector.count_for_user(host.id) == 3 

2874 push = push_collector.pop_for_user(host.id, last=False) 

2875 assert push.content.title == f"New host request from {surfer.name}" 

2876 

2877 

2878def test_host_request_status_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator): 

2879 """ 

2880 Test that status change notifications (accept/reject/etc.) are NOT sent 

2881 for host requests that haven't been approved yet. 

2882 

2883 Note: In practice, the host can't even SEE the request to accept/reject it 

2884 when it's SHADOWED. But if they somehow did, we still shouldn't notify. 

2885 """ 

2886 host, host_token = generate_user(complete_profile=True) 

2887 surfer, surfer_token = generate_user(complete_profile=True) 

2888 

2889 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

2890 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

2891 

2892 # Create host request 

2893 with requests_session(surfer_token) as api: 

2894 hr_id = api.CreateHostRequest( 

2895 requests_pb2.CreateHostRequestReq( 

2896 host_user_id=host.id, 

2897 from_date=today_plus_2, 

2898 to_date=today_plus_3, 

2899 text=valid_request_text(), 

2900 ) 

2901 ).host_request_id 

2902 

2903 # No notifications should have been sent to the host (request is SHADOWED) 

2904 assert push_collector.count_for_user(host.id) == 0 

2905 

2906 # The surfer can cancel their own request even when SHADOWED 

2907 # But this should NOT notify the host since the request isn't approved 

2908 with requests_session(surfer_token) as api: 

2909 api.RespondHostRequest( 

2910 requests_pb2.RespondHostRequestReq( 

2911 host_request_id=hr_id, 

2912 status=messages_pb2.HOST_REQUEST_STATUS_CANCELLED, 

2913 text="Actually, never mind", 

2914 ) 

2915 ) 

2916 

2917 # Host should STILL have no notifications (cancel notification suppressed) 

2918 assert push_collector.count_for_user(host.id) == 0 

2919 

2920 

2921def test_host_request_notifications_sent_after_approval( 

2922 db, email_collector: EmailCollector, push_collector: PushCollector, moderator 

2923): 

2924 """ 

2925 Test that after a host request is approved, all notifications work normally. 

2926 """ 

2927 host, host_token = generate_user(complete_profile=True) 

2928 surfer, surfer_token = generate_user(complete_profile=True) 

2929 

2930 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

2931 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

2932 

2933 # Create and approve host request 

2934 with requests_session(surfer_token) as api: 

2935 hr_id = api.CreateHostRequest( 

2936 requests_pb2.CreateHostRequestReq( 

2937 host_user_id=host.id, 

2938 from_date=today_plus_2, 

2939 to_date=today_plus_3, 

2940 text=valid_request_text(), 

2941 ) 

2942 ).host_request_id 

2943 

2944 moderator.approve_host_request(hr_id) 

2945 

2946 # Host should have received 1 notification (the approval notification) 

2947 email_collector.pop_for_recipient(host.email, last=True) 

2948 push_collector.pop_for_user(host.id, last=True) 

2949 

2950 # Host accepts the request - surfer should be notified 

2951 with requests_session(host_token) as api: 

2952 api.RespondHostRequest( 

2953 requests_pb2.RespondHostRequestReq( 

2954 host_request_id=hr_id, 

2955 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

2956 text="Sure, come on over!", 

2957 ) 

2958 ) 

2959 

2960 # Surfer should have 1 notification (the accept notification) 

2961 email_collector.pop_for_recipient(surfer.email, last=True) 

2962 push = push_collector.pop_for_user(surfer.id, last=True) 

2963 assert push.content.title == f"{host.name} accepted your host request" 

2964 

2965 # Surfer confirms - host should be notified 

2966 with requests_session(surfer_token) as api: 

2967 api.RespondHostRequest( 

2968 requests_pb2.RespondHostRequestReq( 

2969 host_request_id=hr_id, 

2970 status=messages_pb2.HOST_REQUEST_STATUS_CONFIRMED, 

2971 text="See you then!", 

2972 ) 

2973 ) 

2974 

2975 # Host should now have received the confirmation notifications 

2976 email_collector.pop_for_recipient(host.email, last=True) 

2977 push = push_collector.pop_for_user(host.id, last=True) 

2978 assert push.content.title == f"{surfer.name} confirmed their host request" 

2979 

2980 

2981def test_group_chat_message_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator): 

2982 """ 

2983 Test that notifications are NOT sent for messages in group chats 

2984 that haven't been approved yet. 

2985 """ 

2986 user1, token1 = generate_user(complete_profile=True) 

2987 user2, token2 = generate_user(complete_profile=True) 

2988 

2989 # Create a group chat (starts in SHADOWED state) 

2990 with conversations_session(token1) as api: 

2991 res = api.CreateGroupChat( 

2992 conversations_pb2.CreateGroupChatReq( 

2993 recipient_user_ids=[user2.id], 

2994 ) 

2995 ) 

2996 gc_id = res.group_chat_id 

2997 

2998 # Verify initial state 

2999 with session_scope() as session: 

3000 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one() 

3001 assert gc.moderation_state.visibility == ModerationVisibility.shadowed 

3002 

3003 # No notifications should have been sent yet (chat is SHADOWED) 

3004 assert push_collector.count_for_user(user2.id) == 0 

3005 

3006 # Send messages BEFORE approval 

3007 with conversations_session(token1) as api: 

3008 api.SendMessage( 

3009 conversations_pb2.SendMessageReq( 

3010 group_chat_id=gc_id, 

3011 text="Hello before approval", 

3012 ) 

3013 ) 

3014 

3015 # Process the queued notification job 

3016 while process_job(): 

3017 pass 

3018 

3019 # User2 should STILL have no notifications (chat is SHADOWED) 

3020 assert push_collector.count_for_user(user2.id) == 0 

3021 

3022 # Now approve the group chat 

3023 moderator.approve_group_chat(gc_id) 

3024 

3025 # Process the queued notification jobs from approval 

3026 while process_job(): 

3027 pass 

3028 

3029 # Verify moderation state after approval 

3030 with session_scope() as session: 

3031 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one() 

3032 assert gc.moderation_state.visibility == ModerationVisibility.visible 

3033 

3034 # User2 should have received 1 notification for the first message sent before approval 

3035 push = push_collector.pop_for_user(user2.id, last=True) 

3036 assert push.content.title == user1.name 

3037 assert push.content.body == "Hello before approval" 

3038 

3039 # Send a message AFTER approval 

3040 with conversations_session(token1) as api: 

3041 api.SendMessage( 

3042 conversations_pb2.SendMessageReq( 

3043 group_chat_id=gc_id, 

3044 text="Hello after approval", 

3045 ) 

3046 ) 

3047 

3048 # Process the queued notification job 

3049 while process_job(): 

3050 pass 

3051 

3052 # User2 should have received another notification 

3053 assert push_collector.count_for_user(user2.id) == 1 

3054 

3055 

3056def test_event_moderation_state_content(db): 

3057 """Test that event moderation state content includes both title and description""" 

3058 super_user, super_token = generate_user(is_superuser=True) 

3059 user, token = generate_user() 

3060 

3061 with session_scope() as session: 

3062 create_community(session, 0, 2, "Community", [user], [], None) 

3063 

3064 res = events_api.create_event(token=token, title="My Event Title", content="My event description.") 

3065 event_id = res.event_id 

3066 

3067 with session_scope() as session: 

3068 occurrence = session.execute(select(EventOccurrence).where(EventOccurrence.id == event_id)).scalar_one() 

3069 state_id = occurrence.moderation_state_id 

3070 

3071 with real_moderation_session(super_token) as api: 

3072 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq()) 

3073 event_items = [ 

3074 item 

3075 for item in res.queue_items 

3076 if item.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE 

3077 ] 

3078 assert len(event_items) == 1 

3079 assert event_items[0].moderation_state.content == "My Event Title\n\nMy event description." 

3080 

3081 

3082# ============================================================================ 

3083# Tests for SetUserContentVisibility 

3084# ============================================================================ 

3085 

3086 

3087def _get_moderation_state(session, object_type, object_id): 

3088 return session.execute( 

3089 select(ModerationState) 

3090 .where(ModerationState.object_type == object_type) 

3091 .where(ModerationState.object_id == object_id) 

3092 ).scalar_one() 

3093 

3094 

3095def test_SetUserContentVisibility_host_request(db): 

3096 super_user, super_token = generate_user(is_superuser=True) 

3097 surfer, surfer_token = generate_user() 

3098 host, _ = generate_user() 

3099 

3100 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

3101 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

3102 with requests_session(surfer_token) as api: 

3103 hr_id = api.CreateHostRequest( 

3104 requests_pb2.CreateHostRequestReq( 

3105 host_user_id=host.id, 

3106 from_date=today_plus_2, 

3107 to_date=today_plus_3, 

3108 text=valid_request_text(), 

3109 ) 

3110 ).host_request_id 

3111 

3112 with real_moderation_session(super_token) as api: 

3113 res = api.SetUserContentVisibility( 

3114 moderation_pb2.SetUserContentVisibilityReq( 

3115 user_id=surfer.id, 

3116 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

3117 ) 

3118 ) 

3119 # Already shadowed by default — no-op 

3120 assert res.updated_count == 0 

3121 

3122 with real_moderation_session(super_token) as api: 

3123 res = api.SetUserContentVisibility( 

3124 moderation_pb2.SetUserContentVisibilityReq( 

3125 user_id=surfer.id, 

3126 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3127 reason="policy violation", 

3128 ) 

3129 ) 

3130 assert res.updated_count == 1 

3131 

3132 with session_scope() as session: 

3133 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id) 

3134 assert state.visibility == ModerationVisibility.hidden 

3135 

3136 log_entries = ( 

3137 session.execute( 

3138 select(ModerationLog) 

3139 .where(ModerationLog.moderation_state_id == state.id) 

3140 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc()) 

3141 ) 

3142 .scalars() 

3143 .all() 

3144 ) 

3145 # create log + bulk update log 

3146 assert len(log_entries) == 2 

3147 assert log_entries[-1].new_visibility == ModerationVisibility.hidden 

3148 assert log_entries[-1].moderator_user_id == super_user.id 

3149 assert log_entries[-1].reason == "policy violation" 

3150 

3151 

3152def test_SetUserContentVisibility_group_chat(db): 

3153 super_user, super_token = generate_user(is_superuser=True) 

3154 creator, creator_token = generate_user() 

3155 other, _ = generate_user() 

3156 make_friends(creator, other) 

3157 

3158 with conversations_session(creator_token) as api: 

3159 gc_id = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[other.id])).group_chat_id 

3160 

3161 with real_moderation_session(super_token) as api: 

3162 api.SetUserContentVisibility( 

3163 moderation_pb2.SetUserContentVisibilityReq( 

3164 user_id=creator.id, 

3165 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3166 ) 

3167 ) 

3168 

3169 with session_scope() as session: 

3170 state = _get_moderation_state(session, ModerationObjectType.group_chat, gc_id) 

3171 assert state.visibility == ModerationVisibility.hidden 

3172 

3173 

3174def test_SetUserContentVisibility_event_occurrence(db): 

3175 super_user, super_token = generate_user(is_superuser=True) 

3176 creator, creator_token = generate_user() 

3177 

3178 with session_scope() as session: 

3179 create_community(session, 0, 2, "Community", [creator], [], None) 

3180 

3181 event_id = events_api.create_event(token=creator_token).event_id 

3182 

3183 with real_moderation_session(super_token) as api: 

3184 res = api.SetUserContentVisibility( 

3185 moderation_pb2.SetUserContentVisibilityReq( 

3186 user_id=creator.id, 

3187 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3188 ) 

3189 ) 

3190 assert res.updated_count == 1 

3191 

3192 with session_scope() as session: 

3193 state = _get_moderation_state(session, ModerationObjectType.event_occurrence, event_id) 

3194 assert state.visibility == ModerationVisibility.hidden 

3195 

3196 

3197def test_SetUserContentVisibility_friend_request(db): 

3198 super_user, super_token = generate_user(is_superuser=True) 

3199 sender, sender_token = generate_user() 

3200 recipient, _ = generate_user() 

3201 

3202 with api_session(sender_token) as api: 

3203 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=recipient.id)) 

3204 

3205 with session_scope() as session: 

3206 fr_id = session.execute( 

3207 select(FriendRelationship.id).where(FriendRelationship.from_user_id == sender.id) 

3208 ).scalar_one() 

3209 

3210 with real_moderation_session(super_token) as api: 

3211 res = api.SetUserContentVisibility( 

3212 moderation_pb2.SetUserContentVisibilityReq( 

3213 user_id=sender.id, 

3214 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3215 ) 

3216 ) 

3217 assert res.updated_count == 1 

3218 

3219 with session_scope() as session: 

3220 state = _get_moderation_state(session, ModerationObjectType.friend_request, fr_id) 

3221 assert state.visibility == ModerationVisibility.hidden 

3222 

3223 

3224def test_SetUserContentVisibility_round_trip(db): 

3225 super_user, super_token = generate_user(is_superuser=True) 

3226 surfer, surfer_token = generate_user() 

3227 host, _ = generate_user() 

3228 

3229 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

3230 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

3231 with requests_session(surfer_token) as api: 

3232 hr_id = api.CreateHostRequest( 

3233 requests_pb2.CreateHostRequestReq( 

3234 host_user_id=host.id, 

3235 from_date=today_plus_2, 

3236 to_date=today_plus_3, 

3237 text=valid_request_text(), 

3238 ) 

3239 ).host_request_id 

3240 

3241 with real_moderation_session(super_token) as api: 

3242 api.SetUserContentVisibility( 

3243 moderation_pb2.SetUserContentVisibilityReq( 

3244 user_id=surfer.id, 

3245 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3246 reason="first", 

3247 ) 

3248 ) 

3249 api.SetUserContentVisibility( 

3250 moderation_pb2.SetUserContentVisibilityReq( 

3251 user_id=surfer.id, 

3252 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

3253 reason="second", 

3254 ) 

3255 ) 

3256 

3257 with session_scope() as session: 

3258 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id) 

3259 assert state.visibility == ModerationVisibility.visible 

3260 

3261 bulk_log_entries = ( 

3262 session.execute( 

3263 select(ModerationLog) 

3264 .where(ModerationLog.moderation_state_id == state.id) 

3265 .where(ModerationLog.reason.in_(("first", "second"))) 

3266 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc()) 

3267 ) 

3268 .scalars() 

3269 .all() 

3270 ) 

3271 assert [entry.new_visibility for entry in bulk_log_entries] == [ 

3272 ModerationVisibility.hidden, 

3273 ModerationVisibility.visible, 

3274 ] 

3275 

3276 

3277def test_SetUserContentVisibility_resolves_queue_items(db): 

3278 super_user, super_token = generate_user(is_superuser=True) 

3279 surfer, surfer_token = generate_user() 

3280 host, _ = generate_user() 

3281 

3282 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

3283 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

3284 with requests_session(surfer_token) as api: 

3285 hr_id = api.CreateHostRequest( 

3286 requests_pb2.CreateHostRequestReq( 

3287 host_user_id=host.id, 

3288 from_date=today_plus_2, 

3289 to_date=today_plus_3, 

3290 text=valid_request_text(), 

3291 ) 

3292 ).host_request_id 

3293 

3294 with session_scope() as session: 

3295 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id) 

3296 queue_item = session.execute( 

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

3298 ).scalar_one() 

3299 assert queue_item.resolved_by_log_id is None 

3300 

3301 with real_moderation_session(super_token) as api: 

3302 api.SetUserContentVisibility( 

3303 moderation_pb2.SetUserContentVisibilityReq( 

3304 user_id=surfer.id, 

3305 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3306 ) 

3307 ) 

3308 

3309 with session_scope() as session: 

3310 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id) 

3311 queue_item = session.execute( 

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

3313 ).scalar_one() 

3314 assert queue_item.resolved_by_log_id is not None 

3315 

3316 

3317def test_SetUserContentVisibility_noop_when_matches(db): 

3318 super_user, super_token = generate_user(is_superuser=True) 

3319 surfer, surfer_token = generate_user() 

3320 host, _ = generate_user() 

3321 

3322 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

3323 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

3324 with requests_session(surfer_token) as api: 

3325 api.CreateHostRequest( 

3326 requests_pb2.CreateHostRequestReq( 

3327 host_user_id=host.id, 

3328 from_date=today_plus_2, 

3329 to_date=today_plus_3, 

3330 text=valid_request_text(), 

3331 ) 

3332 ) 

3333 

3334 with session_scope() as session: 

3335 log_count_before = len(session.execute(select(ModerationLog)).scalars().all()) 

3336 

3337 with real_moderation_session(super_token) as api: 

3338 res = api.SetUserContentVisibility( 

3339 moderation_pb2.SetUserContentVisibilityReq( 

3340 user_id=surfer.id, 

3341 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

3342 ) 

3343 ) 

3344 assert res.updated_count == 0 

3345 

3346 with session_scope() as session: 

3347 log_count_after = len(session.execute(select(ModerationLog)).scalars().all()) 

3348 assert log_count_after == log_count_before 

3349 

3350 

3351def test_SetUserContentVisibility_unspecified_rejected(db): 

3352 super_user, super_token = generate_user(is_superuser=True) 

3353 target, _ = generate_user() 

3354 

3355 with real_moderation_session(super_token) as api: 

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

3357 api.SetUserContentVisibility( 

3358 moderation_pb2.SetUserContentVisibilityReq( 

3359 user_id=target.id, 

3360 visibility=moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED, 

3361 ) 

3362 ) 

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

3364 

3365 

3366def test_SetUserContentVisibility_non_admin_rejected(db): 

3367 normal_user, normal_token = generate_user() 

3368 target, _ = generate_user() 

3369 

3370 with real_moderation_session(normal_token) as api: 

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

3372 api.SetUserContentVisibility( 

3373 moderation_pb2.SetUserContentVisibilityReq( 

3374 user_id=target.id, 

3375 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3376 ) 

3377 ) 

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

3379 

3380 

3381def test_SetUserContentVisibility_writes_admin_action(db): 

3382 super_user, super_token = generate_user(is_superuser=True) 

3383 surfer, surfer_token = generate_user() 

3384 host, _ = generate_user() 

3385 

3386 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

3387 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

3388 with requests_session(surfer_token) as api: 

3389 api.CreateHostRequest( 

3390 requests_pb2.CreateHostRequestReq( 

3391 host_user_id=host.id, 

3392 from_date=today_plus_2, 

3393 to_date=today_plus_3, 

3394 text=valid_request_text(), 

3395 ) 

3396 ) 

3397 

3398 with real_moderation_session(super_token) as api: 

3399 api.SetUserContentVisibility( 

3400 moderation_pb2.SetUserContentVisibilityReq( 

3401 user_id=surfer.id, 

3402 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3403 reason="bulk hide", 

3404 ) 

3405 ) 

3406 

3407 with session_scope() as session: 

3408 actions = ( 

3409 session.execute( 

3410 select(AdminAction) 

3411 .where(AdminAction.target_user_id == surfer.id) 

3412 .where(AdminAction.action_type == "set_user_content_visibility") 

3413 ) 

3414 .scalars() 

3415 .all() 

3416 ) 

3417 assert len(actions) == 1 

3418 assert actions[0].admin_user_id == super_user.id 

3419 assert actions[0].tag == "hidden" 

3420 assert actions[0].note == "bulk hide" 

3421 

3422 

3423def test_SetUserContentVisibility_only_touches_target(db): 

3424 super_user, super_token = generate_user(is_superuser=True) 

3425 target, target_token = generate_user() 

3426 other, other_token = generate_user() 

3427 host, _ = generate_user() 

3428 

3429 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

3430 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

3431 

3432 with requests_session(target_token) as api: 

3433 target_hr_id = api.CreateHostRequest( 

3434 requests_pb2.CreateHostRequestReq( 

3435 host_user_id=host.id, 

3436 from_date=today_plus_2, 

3437 to_date=today_plus_3, 

3438 text=valid_request_text(), 

3439 ) 

3440 ).host_request_id 

3441 

3442 with requests_session(other_token) as api: 

3443 other_hr_id = api.CreateHostRequest( 

3444 requests_pb2.CreateHostRequestReq( 

3445 host_user_id=host.id, 

3446 from_date=today_plus_2, 

3447 to_date=today_plus_3, 

3448 text=valid_request_text(), 

3449 ) 

3450 ).host_request_id 

3451 

3452 with real_moderation_session(super_token) as api: 

3453 res = api.SetUserContentVisibility( 

3454 moderation_pb2.SetUserContentVisibilityReq( 

3455 user_id=target.id, 

3456 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3457 ) 

3458 ) 

3459 assert res.updated_count == 1 

3460 

3461 with session_scope() as session: 

3462 target_state = _get_moderation_state(session, ModerationObjectType.host_request, target_hr_id) 

3463 other_state = _get_moderation_state(session, ModerationObjectType.host_request, other_hr_id) 

3464 assert target_state.visibility == ModerationVisibility.hidden 

3465 assert other_state.visibility == ModerationVisibility.shadowed 

3466 

3467 

3468def test_SetUserContentVisibility_user_not_found(db): 

3469 super_user, super_token = generate_user(is_superuser=True) 

3470 

3471 with real_moderation_session(super_token) as api: 

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

3473 api.SetUserContentVisibility( 

3474 moderation_pb2.SetUserContentVisibilityReq( 

3475 user_id=999999, 

3476 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3477 ) 

3478 ) 

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

3480 

3481 

3482def test_SetUserContentVisibility_from_visibility_filter(db): 

3483 super_user, super_token = generate_user(is_superuser=True) 

3484 surfer, surfer_token = generate_user() 

3485 host, _ = generate_user() 

3486 

3487 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

3488 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

3489 with requests_session(surfer_token) as api: 

3490 hr_id = api.CreateHostRequest( 

3491 requests_pb2.CreateHostRequestReq( 

3492 host_user_id=host.id, 

3493 from_date=today_plus_2, 

3494 to_date=today_plus_3, 

3495 text=valid_request_text(), 

3496 ) 

3497 ).host_request_id 

3498 

3499 with real_moderation_session(super_token) as api: 

3500 api.SetUserContentVisibility( 

3501 moderation_pb2.SetUserContentVisibilityReq( 

3502 user_id=surfer.id, 

3503 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

3504 ) 

3505 ) 

3506 

3507 with real_moderation_session(super_token) as api: 

3508 res = api.SetUserContentVisibility( 

3509 moderation_pb2.SetUserContentVisibilityReq( 

3510 user_id=surfer.id, 

3511 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3512 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_SHADOWED], 

3513 ) 

3514 ) 

3515 assert res.updated_count == 0 

3516 

3517 with session_scope() as session: 

3518 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id) 

3519 assert state.visibility == ModerationVisibility.visible 

3520 

3521 with real_moderation_session(super_token) as api: 

3522 res = api.SetUserContentVisibility( 

3523 moderation_pb2.SetUserContentVisibilityReq( 

3524 user_id=surfer.id, 

3525 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

3526 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_VISIBLE], 

3527 ) 

3528 ) 

3529 assert res.updated_count == 1 

3530 

3531 with session_scope() as session: 

3532 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id) 

3533 assert state.visibility == ModerationVisibility.shadowed 

3534 

3535 

3536def test_SetUserContentVisibility_from_visibility_multi(db): 

3537 super_user, super_token = generate_user(is_superuser=True) 

3538 surfer, surfer_token = generate_user() 

3539 host1, _ = generate_user() 

3540 host2, _ = generate_user() 

3541 

3542 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

3543 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

3544 with requests_session(surfer_token) as api: 

3545 hr1_id = api.CreateHostRequest( 

3546 requests_pb2.CreateHostRequestReq( 

3547 host_user_id=host1.id, 

3548 from_date=today_plus_2, 

3549 to_date=today_plus_3, 

3550 text=valid_request_text(), 

3551 ) 

3552 ).host_request_id 

3553 hr2_id = api.CreateHostRequest( 

3554 requests_pb2.CreateHostRequestReq( 

3555 host_user_id=host2.id, 

3556 from_date=today_plus_2, 

3557 to_date=today_plus_3, 

3558 text=valid_request_text(), 

3559 ) 

3560 ).host_request_id 

3561 

3562 with session_scope() as session: 

3563 state1_id = _get_moderation_state(session, ModerationObjectType.host_request, hr1_id).id 

3564 

3565 with real_moderation_session(super_token) as api: 

3566 api.ModerateContent( 

3567 moderation_pb2.ModerateContentReq( 

3568 moderation_state_id=state1_id, 

3569 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

3570 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

3571 ) 

3572 ) 

3573 

3574 with real_moderation_session(super_token) as api: 

3575 res = api.SetUserContentVisibility( 

3576 moderation_pb2.SetUserContentVisibilityReq( 

3577 user_id=surfer.id, 

3578 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3579 from_visibility=[ 

3580 moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

3581 moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

3582 ], 

3583 ) 

3584 ) 

3585 assert res.updated_count == 2 

3586 

3587 with session_scope() as session: 

3588 state1 = _get_moderation_state(session, ModerationObjectType.host_request, hr1_id) 

3589 state2 = _get_moderation_state(session, ModerationObjectType.host_request, hr2_id) 

3590 assert state1.visibility == ModerationVisibility.hidden 

3591 assert state2.visibility == ModerationVisibility.hidden 

3592 

3593 

3594def test_SetUserContentVisibility_from_visibility_empty_is_any(db): 

3595 super_user, super_token = generate_user(is_superuser=True) 

3596 surfer, surfer_token = generate_user() 

3597 host, _ = generate_user() 

3598 

3599 today_plus_2 = (today() + timedelta(days=2)).isoformat() 

3600 today_plus_3 = (today() + timedelta(days=3)).isoformat() 

3601 with requests_session(surfer_token) as api: 

3602 hr_id = api.CreateHostRequest( 

3603 requests_pb2.CreateHostRequestReq( 

3604 host_user_id=host.id, 

3605 from_date=today_plus_2, 

3606 to_date=today_plus_3, 

3607 text=valid_request_text(), 

3608 ) 

3609 ).host_request_id 

3610 

3611 with real_moderation_session(super_token) as api: 

3612 res = api.SetUserContentVisibility( 

3613 moderation_pb2.SetUserContentVisibilityReq( 

3614 user_id=surfer.id, 

3615 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3616 from_visibility=[], 

3617 ) 

3618 ) 

3619 assert res.updated_count == 1 

3620 

3621 with session_scope() as session: 

3622 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id) 

3623 assert state.visibility == ModerationVisibility.hidden 

3624 

3625 

3626def test_SetUserContentVisibility_from_visibility_unspecified_rejected(db): 

3627 super_user, super_token = generate_user(is_superuser=True) 

3628 target, _ = generate_user() 

3629 

3630 with real_moderation_session(super_token) as api: 

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

3632 api.SetUserContentVisibility( 

3633 moderation_pb2.SetUserContentVisibilityReq( 

3634 user_id=target.id, 

3635 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3636 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED], 

3637 ) 

3638 ) 

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

3640 

3641 

3642def _content_state_ids(res: moderation_pb2.ListModerationStatesRes) -> list[int]: 

3643 """Every user has a moderation state, so listings mix them in with the content states.""" 

3644 return [ 

3645 s.moderation_state_id 

3646 for s in res.moderation_states 

3647 if s.object_type != moderation_pb2.MODERATION_OBJECT_TYPE_USER 

3648 ] 

3649 

3650 

3651def test_ListModerationStates_with_no_content(db): 

3652 super_user, super_token = generate_user(is_superuser=True) 

3653 

3654 with real_moderation_session(super_token) as api: 

3655 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq()) 

3656 assert [s.object_id for s in res.moderation_states] == [super_user.id] 

3657 assert _content_state_ids(res) == [] 

3658 assert res.next_page_token == "" 

3659 

3660 

3661def test_ListModerationStates_returns_states_chronologically(db): 

3662 super_user, super_token = generate_user(is_superuser=True) 

3663 surfer, surfer_token = generate_user() 

3664 host, _ = generate_user() 

3665 

3666 state1_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3667 state2_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3668 state3_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3669 

3670 with real_moderation_session(super_token) as api: 

3671 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq()) 

3672 assert _content_state_ids(res) == [state1_id, state2_id, state3_id] 

3673 

3674 res_newest = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(newest_first=True)) 

3675 assert _content_state_ids(res_newest) == [state3_id, state2_id, state1_id] 

3676 

3677 

3678def test_ListModerationStates_filter_by_author(db): 

3679 super_user, super_token = generate_user(is_superuser=True) 

3680 surfer1, surfer1_token = generate_user() 

3681 surfer2, surfer2_token = generate_user() 

3682 host, _ = generate_user() 

3683 

3684 state1_id = create_test_host_request_with_moderation(surfer1_token, host.id) 

3685 state2_id = create_test_host_request_with_moderation(surfer2_token, host.id) 

3686 state3_id = create_test_host_request_with_moderation(surfer1_token, host.id) 

3687 

3688 with real_moderation_session(super_token) as api: 

3689 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer1.id)) 

3690 assert set(_content_state_ids(res)) == {state1_id, state3_id} 

3691 user_states = [s for s in res.moderation_states if s.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_USER] 

3692 assert [s.object_id for s in user_states] == [surfer1.id] 

3693 

3694 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer2.id)) 

3695 assert _content_state_ids(res) == [state2_id] 

3696 

3697 

3698def test_ListModerationStates_pagination(db): 

3699 super_user, super_token = generate_user(is_superuser=True) 

3700 surfer, surfer_token = generate_user() 

3701 host, _ = generate_user() 

3702 

3703 for _ in range(3): 

3704 create_test_host_request_with_moderation(surfer_token, host.id) 

3705 

3706 with real_moderation_session(super_token) as api: 

3707 # 3 users plus 3 host requests 

3708 all_ids = [ 

3709 s.moderation_state_id 

3710 for s in api.ListModerationStates(moderation_pb2.ListModerationStatesReq()).moderation_states 

3711 ] 

3712 assert len(all_ids) == 6 

3713 

3714 paged_ids = [] 

3715 page_token = "" 

3716 while True: 

3717 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(page_size=2, page_token=page_token)) 

3718 assert len(res.moderation_states) == 2 

3719 paged_ids += [s.moderation_state_id for s in res.moderation_states] 

3720 page_token = res.next_page_token 

3721 if not page_token: 

3722 break 

3723 

3724 assert paged_ids == all_ids 

3725 

3726 

3727def test_ListModerationStates_pagination_ignores_created(db): 

3728 """Backfilled states carry the migration's timestamp, so created and id disagree on their order.""" 

3729 super_user, super_token = generate_user(is_superuser=True) 

3730 surfer, surfer_token = generate_user() 

3731 host, _ = generate_user() 

3732 

3733 first_state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3734 create_test_host_request_with_moderation(surfer_token, host.id) 

3735 create_test_host_request_with_moderation(surfer_token, host.id) 

3736 

3737 with session_scope() as session: 

3738 state = session.execute(select(ModerationState).where(ModerationState.id == first_state_id)).scalar_one() 

3739 state.created = now() + timedelta(days=1) 

3740 

3741 paged_ids = [] 

3742 page_token = "" 

3743 with real_moderation_session(super_token) as api: 

3744 while True: 

3745 res = api.ListModerationStates( 

3746 moderation_pb2.ListModerationStatesReq(page_size=2, page_token=page_token, newest_first=True) 

3747 ) 

3748 paged_ids += [s.moderation_state_id for s in res.moderation_states] 

3749 page_token = res.next_page_token 

3750 if not page_token: 

3751 break 

3752 

3753 with session_scope() as session: 

3754 expected = list(session.execute(select(ModerationState.id).order_by(ModerationState.id.desc())).scalars().all()) 

3755 

3756 assert paged_ids == expected 

3757 

3758 

3759# ============================================================================ 

3760# Tests for user account moderation states 

3761# ============================================================================ 

3762 

3763 

3764def test_user_moderation_state_created_with_the_account(db): 

3765 user, _ = generate_user() 

3766 

3767 with session_scope() as session: 

3768 state = session.execute( 

3769 select(ModerationState) 

3770 .where(ModerationState.object_type == ModerationObjectType.user) 

3771 .where(ModerationState.object_id == user.id) 

3772 ).scalar_one() 

3773 

3774 assert state.visibility is None 

3775 assert session.execute(select(User.moderation_state_id).where(User.id == user.id)).scalar_one() == state.id 

3776 

3777 log_entries = ( 

3778 session.execute(select(ModerationLog).where(ModerationLog.moderation_state_id == state.id)).scalars().all() 

3779 ) 

3780 assert len(log_entries) == 1 

3781 assert log_entries[0].action == ModerationAction.create 

3782 assert log_entries[0].moderator_user_id == user.id 

3783 

3784 queue_items = ( 

3785 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id)) 

3786 .scalars() 

3787 .all() 

3788 ) 

3789 assert queue_items == [] 

3790 

3791 

3792def test_GetModerationState_for_user(db): 

3793 super_user, super_token = generate_user(is_superuser=True) 

3794 user, _ = generate_user() 

3795 

3796 with real_moderation_session(super_token) as api: 

3797 state = api.GetModerationState( 

3798 moderation_pb2.GetModerationStateReq( 

3799 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3800 object_id=user.id, 

3801 ) 

3802 ).moderation_state 

3803 assert state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_USER 

3804 assert state.object_id == user.id 

3805 assert state.author_user_id == user.id 

3806 assert state.author.user_id == user.id 

3807 assert state.content == f"@{user.username} / {user.id}" 

3808 assert state.visibility == moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED 

3809 

3810 

3811def test_GetModerationState_for_nonexistent_user(db): 

3812 super_user, super_token = generate_user(is_superuser=True) 

3813 

3814 with real_moderation_session(super_token) as api: 

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

3816 api.GetModerationState( 

3817 moderation_pb2.GetModerationStateReq( 

3818 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3819 object_id=999999, 

3820 ) 

3821 ) 

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

3823 

3824 

3825def test_flag_user_shows_up_in_queue_and_author_filters(db): 

3826 super_user, super_token = generate_user(is_superuser=True) 

3827 surfer, surfer_token = generate_user() 

3828 host, _ = generate_user() 

3829 other, _ = generate_user() 

3830 

3831 content_state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3832 

3833 with real_moderation_session(super_token) as api: 

3834 user_state_id = api.GetModerationState( 

3835 moderation_pb2.GetModerationStateReq( 

3836 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3837 object_id=surfer.id, 

3838 ) 

3839 ).moderation_state.moderation_state_id 

3840 

3841 api.ModerateContent( 

3842 moderation_pb2.ModerateContentReq( 

3843 moderation_state_id=user_state_id, 

3844 action=moderation_pb2.MODERATION_ACTION_FLAG, 

3845 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

3846 reason="Reported for spamming hosts", 

3847 ) 

3848 ) 

3849 

3850 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=surfer.id)) 

3851 assert {item.moderation_state_id for item in res.queue_items} == {content_state_id, user_state_id} 

3852 user_item = next(item for item in res.queue_items if item.moderation_state_id == user_state_id) 

3853 assert user_item.trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG 

3854 assert user_item.reason == "Reported for spamming hosts" 

3855 assert user_item.moderation_state.author.user_id == surfer.id 

3856 

3857 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=other.id)) 

3858 assert len(res.queue_items) == 0 

3859 

3860 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer.id)) 

3861 assert {s.moderation_state_id for s in res.moderation_states} == {content_state_id, user_state_id} 

3862 

3863 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=other.id)) 

3864 assert [s.object_id for s in res.moderation_states] == [other.id] 

3865 

3866 

3867@pytest.mark.parametrize("action", [moderation_pb2.MODERATION_ACTION_APPROVE, moderation_pb2.MODERATION_ACTION_HIDE]) 

3868def test_ModerateContent_cannot_set_visibility_on_user_state(db, action): 

3869 super_user, super_token = generate_user(is_superuser=True) 

3870 user, _ = generate_user() 

3871 

3872 with real_moderation_session(super_token) as api: 

3873 user_state_id = api.GetModerationState( 

3874 moderation_pb2.GetModerationStateReq( 

3875 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3876 object_id=user.id, 

3877 ) 

3878 ).moderation_state.moderation_state_id 

3879 

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

3881 api.ModerateContent( 

3882 moderation_pb2.ModerateContentReq( 

3883 moderation_state_id=user_state_id, 

3884 action=action, 

3885 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3886 ) 

3887 ) 

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

3889 

3890 with session_scope() as session: 

3891 state = session.execute(select(ModerationState).where(ModerationState.id == user_state_id)).scalar_one() 

3892 assert state.visibility is None 

3893 

3894 

3895def test_SetUserContentVisibility_leaves_user_state_alone(db): 

3896 super_user, super_token = generate_user(is_superuser=True) 

3897 surfer, surfer_token = generate_user() 

3898 host, _ = generate_user() 

3899 

3900 content_state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3901 

3902 with real_moderation_session(super_token) as api: 

3903 user_state_id = api.GetModerationState( 

3904 moderation_pb2.GetModerationStateReq( 

3905 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3906 object_id=surfer.id, 

3907 ) 

3908 ).moderation_state.moderation_state_id 

3909 

3910 res = api.SetUserContentVisibility( 

3911 moderation_pb2.SetUserContentVisibilityReq( 

3912 user_id=surfer.id, 

3913 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3914 ) 

3915 ) 

3916 assert res.updated_count == 1 

3917 

3918 with session_scope() as session: 

3919 content_state = session.execute( 

3920 select(ModerationState).where(ModerationState.id == content_state_id) 

3921 ).scalar_one() 

3922 assert content_state.visibility == ModerationVisibility.hidden 

3923 

3924 user_state = session.execute(select(ModerationState).where(ModerationState.id == user_state_id)).scalar_one() 

3925 assert user_state.visibility is None 

3926 

3927 

3928# ============================================================================ 

3929# Tests for the moderation model metadata 

3930# ============================================================================ 

3931 

3932 

3933def test_every_object_type_has_a_model(): 

3934 """A type in the enum with no model behind it is accepted everywhere and understood nowhere.""" 

3935 assert set(get_moderated_models()) == set(ModerationObjectType) 

3936 

3937 

3938@pytest.mark.parametrize("object_type", list(ModerationObjectType)) 

3939def test_visibility_check_constraint_matches_the_models(db, object_type): 

3940 """The check constraint spells out the types with their own visibility mechanism; this makes sure it agrees with the models.""" 

3941 has_own_visibility_mechanism = get_moderated_models()[object_type].has_own_visibility_mechanism 

3942 

3943 def add_state(object_id: int, visibility: ModerationVisibility | None) -> None: 

3944 with session_scope() as session: 

3945 session.add(ModerationState(object_type=object_type, object_id=object_id, visibility=visibility)) 

3946 

3947 if has_own_visibility_mechanism: 

3948 add_state(1, None) 

3949 with pytest.raises(IntegrityError): 

3950 add_state(2, ModerationVisibility.visible) 

3951 else: 

3952 add_state(1, ModerationVisibility.visible) 

3953 with pytest.raises(IntegrityError): 

3954 add_state(2, None) 

3955 

3956 

3957# ============================================================================ 

3958# Tests for GetModeratedObject 

3959# ============================================================================ 

3960 

3961 

3962def _get_moderated_object(token, moderation_state_id): 

3963 with real_moderation_session(token) as api: 

3964 return api.GetModeratedObject(moderation_pb2.GetModeratedObjectReq(moderation_state_id=moderation_state_id)) 

3965 

3966 

3967def _post_reply(token, thread_id, content): 

3968 with threads_session(token) as api: 

3969 return api.PostReply(threads_pb2.PostReplyReq(thread_id=thread_id, content=content)).thread_id 

3970 

3971 

3972def _moderation_state_id(object_type, object_id): 

3973 with session_scope() as session: 

3974 return _get_moderation_state(session, object_type, object_id).id 

3975 

3976 

3977def test_GetModeratedObject_host_request(db): 

3978 super_user, super_token = generate_user(is_superuser=True) 

3979 surfer, surfer_token = generate_user() 

3980 host, _ = generate_user() 

3981 

3982 state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3983 

3984 res = _get_moderated_object(super_token, state_id) 

3985 assert res.moderation_state.moderation_state_id == state_id 

3986 assert res.involved_user_ids == [host.id] 

3987 assert res.url == "" 

3988 assert not res.HasField("community") 

3989 assert not res.HasField("thread_parent") 

3990 

3991 

3992def test_GetModeratedObject_event_occurrence_in_group(db): 

3993 super_user, super_token = generate_user(is_superuser=True) 

3994 user, token = generate_user() 

3995 

3996 with session_scope() as session: 

3997 community = create_community(session, 0, 2, "Event Community", [user], [], None) 

3998 group = create_group(session, "Event Group", [user], [], community) 

3999 community_id = community.id 

4000 group_id = group.id 

4001 

4002 event = events_api.create_event(token=token) 

4003 events_api.transfer_event(token=token, event_id=event.event_id, new_owner_group_id=group_id) 

4004 

4005 res = _get_moderated_object( 

4006 super_token, _moderation_state_id(ModerationObjectType.event_occurrence, event.event_id) 

4007 ) 

4008 assert res.url == urls.event_link(occurrence_id=event.event_id, slug=event.slug) 

4009 assert res.community.community_id == community_id 

4010 assert res.community.community_name == "Event Community" 

4011 assert res.community.group_id == group_id 

4012 assert res.community.group_name == "Event Group" 

4013 

4014 

4015def test_GetModeratedObject_event_occurrence_transferred_to_group_elsewhere(db): 

4016 super_user, super_token = generate_user(is_superuser=True) 

4017 user, token = generate_user() 

4018 

4019 with session_scope() as session: 

4020 create_community(session, 0, 2, "Location Community", [user], [], None) 

4021 other_community = create_community(session, 5, 7, "Other Community", [user], [], None) 

4022 group = create_group(session, "Other Group", [user], [], other_community) 

4023 other_community_id = other_community.id 

4024 group_id = group.id 

4025 

4026 event = events_api.create_event(token=token) 

4027 events_api.transfer_event(token=token, event_id=event.event_id, new_owner_group_id=group_id) 

4028 

4029 res = _get_moderated_object( 

4030 super_token, _moderation_state_id(ModerationObjectType.event_occurrence, event.event_id) 

4031 ) 

4032 assert res.community.community_id == other_community_id 

4033 assert res.community.community_name == "Other Community" 

4034 assert res.community.group_id == group_id 

4035 assert res.community.group_name == "Other Group" 

4036 

4037 

4038def test_GetModeratedObject_comment_on_discussion(db): 

4039 super_user, super_token = generate_user(is_superuser=True) 

4040 user, token = generate_user() 

4041 

4042 with session_scope() as session: 

4043 community_id = create_community(session, 0, 2, "Discussion Community", [user], [], None).id 

4044 

4045 with discussions_session(token) as api: 

4046 discussion = api.CreateDiscussion( 

4047 discussions_pb2.CreateDiscussionReq( 

4048 title="Where to eat?", content="Discussion body.", owner_community_id=community_id 

4049 ) 

4050 ) 

4051 comment_thread_id = _post_reply(token, discussion.thread.thread_id, "Try the noodle place") 

4052 

4053 res = _get_moderated_object( 

4054 super_token, _moderation_state_id(ModerationObjectType.comment, comment_thread_id // 10) 

4055 ) 

4056 assert res.thread_parent.discussion_id == discussion.discussion_id 

4057 assert res.thread_parent.title == "Where to eat?" 

4058 assert res.url == urls.discussion_link(discussion_id=str(discussion.discussion_id), slug=discussion.slug) 

4059 assert res.community.community_id == community_id 

4060 assert res.community.group_id == 0 

4061 

4062 

4063def test_GetModeratedObject_comment_on_page(db): 

4064 super_user, super_token = generate_user(is_superuser=True) 

4065 user, token = generate_user() 

4066 

4067 with session_scope() as session: 

4068 community_id = create_community(session, 0, 2, "Page Community", [user], [], None).id 

4069 page_id, page_thread_id = session.execute( 

4070 select(Page.id, Page.thread_id) 

4071 .where(Page.parent_node_id == community_id) 

4072 .where(Page.type == PageType.main_page) 

4073 ).one() 

4074 

4075 comment_thread_id = _post_reply(token, pack_thread_id(page_thread_id, 0), "Nice page") 

4076 

4077 res = _get_moderated_object( 

4078 super_token, _moderation_state_id(ModerationObjectType.comment, comment_thread_id // 10) 

4079 ) 

4080 assert res.thread_parent.page_id == page_id 

4081 assert res.thread_parent.title == "Main page for the Page Community community" 

4082 assert res.url == "" 

4083 assert res.community.community_id == community_id 

4084 

4085 

4086def test_GetModeratedObject_comment_on_page_transferred_to_group_elsewhere(db): 

4087 super_user, super_token = generate_user(is_superuser=True) 

4088 user, token = generate_user() 

4089 

4090 with session_scope() as session: 

4091 create_community(session, 0, 2, "Location Community", [user], [], None) 

4092 other_community = create_community(session, 5, 7, "Other Community", [user], [], None) 

4093 group = create_group(session, "Other Group", [user], [], other_community) 

4094 other_community_id = other_community.id 

4095 group_id = group.id 

4096 

4097 with pages_session(token) as api: 

4098 place = api.CreatePlace( 

4099 pages_pb2.CreatePlaceReq( 

4100 title="Old Lighthouse", 

4101 content="Place description.", 

4102 address="Near Null Island", 

4103 location=pages_pb2.Coordinate(lat=1, lng=1), 

4104 ) 

4105 ) 

4106 api.TransferPage(pages_pb2.TransferPageReq(page_id=place.page_id, new_owner_group_id=group_id)) 

4107 

4108 comment_thread_id = _post_reply(token, place.thread.thread_id, "Is it open to visitors?") 

4109 

4110 res = _get_moderated_object( 

4111 super_token, _moderation_state_id(ModerationObjectType.comment, comment_thread_id // 10) 

4112 ) 

4113 assert res.thread_parent.page_id == place.page_id 

4114 assert res.thread_parent.title == "Old Lighthouse" 

4115 assert res.community.community_id == other_community_id 

4116 assert res.community.community_name == "Other Community" 

4117 assert res.community.group_id == group_id 

4118 assert res.community.group_name == "Other Group" 

4119 

4120 

4121def test_GetModeratedObject_reply_on_event(db): 

4122 super_user, super_token = generate_user(is_superuser=True) 

4123 user, token = generate_user() 

4124 

4125 with session_scope() as session: 

4126 community_id = create_community(session, 0, 2, "Event Community", [user], [], None).id 

4127 

4128 event = events_api.create_event(token=token, title="Board Games") 

4129 comment_thread_id = _post_reply(token, event.thread.thread_id, "Can I bring a friend?") 

4130 reply_thread_id = _post_reply(token, comment_thread_id, "Sure") 

4131 

4132 with session_scope() as session: 

4133 reply_state_id = session.execute( 

4134 select(Reply.moderation_state_id).where(Reply.id == reply_thread_id // 10) 

4135 ).scalar_one() 

4136 

4137 res = _get_moderated_object(super_token, reply_state_id) 

4138 assert res.parent_comment_id == comment_thread_id // 10 

4139 assert res.parent_comment_content == "Can I bring a friend?" 

4140 assert res.thread_parent.event_occurrence_id == event.event_id 

4141 assert res.thread_parent.title == "Board Games" 

4142 assert res.url == urls.event_link(occurrence_id=event.event_id, slug=event.slug) 

4143 assert res.community.community_id == community_id 

4144 

4145 

4146def test_GetModeratedObject_group_chat(db): 

4147 super_user, super_token = generate_user(is_superuser=True) 

4148 creator, creator_token = generate_user() 

4149 member, _ = generate_user() 

4150 removed, _ = generate_user() 

4151 make_friends(creator, member) 

4152 make_friends(creator, removed) 

4153 

4154 with conversations_session(creator_token) as api: 

4155 group_chat_id = api.CreateGroupChat( 

4156 conversations_pb2.CreateGroupChatReq(recipient_user_ids=[member.id, removed.id]) 

4157 ).group_chat_id 

4158 api.RemoveGroupChatUser( 

4159 conversations_pb2.RemoveGroupChatUserReq(group_chat_id=group_chat_id, user_id=removed.id) 

4160 ) 

4161 

4162 res = _get_moderated_object(super_token, _moderation_state_id(ModerationObjectType.group_chat, group_chat_id)) 

4163 assert res.moderation_state.author_user_id == creator.id 

4164 assert res.involved_user_ids == [member.id] 

4165 assert res.url == "" 

4166 assert not res.HasField("community") 

4167 

4168 

4169def test_GetModeratedObject_friend_request(db): 

4170 super_user, super_token = generate_user(is_superuser=True) 

4171 sender, sender_token = generate_user() 

4172 recipient, _ = generate_user() 

4173 

4174 with api_session(sender_token) as api: 

4175 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=recipient.id)) 

4176 

4177 with session_scope() as session: 

4178 friend_request_id = session.execute( 

4179 select(FriendRelationship.id).where(FriendRelationship.from_user_id == sender.id) 

4180 ).scalar_one() 

4181 

4182 res = _get_moderated_object( 

4183 super_token, _moderation_state_id(ModerationObjectType.friend_request, friend_request_id) 

4184 ) 

4185 assert res.involved_user_ids == [recipient.id] 

4186 assert res.url == "" 

4187 assert not res.HasField("community") 

4188 

4189 

4190def test_GetModeratedObject_discussion_in_group(db): 

4191 super_user, super_token = generate_user(is_superuser=True) 

4192 user, token = generate_user() 

4193 

4194 with session_scope() as session: 

4195 community = create_community(session, 0, 2, "Discussion Community", [user], [], None) 

4196 group = create_group(session, "Discussion Group", [user], [], community) 

4197 community_id = community.id 

4198 group_id = group.id 

4199 

4200 with discussions_session(token) as api: 

4201 discussion = api.CreateDiscussion( 

4202 discussions_pb2.CreateDiscussionReq( 

4203 title="Group hike?", content="Discussion body.", owner_group_id=group_id 

4204 ) 

4205 ) 

4206 

4207 res = _get_moderated_object( 

4208 super_token, _moderation_state_id(ModerationObjectType.discussion, discussion.discussion_id) 

4209 ) 

4210 assert res.url == urls.discussion_link(discussion_id=str(discussion.discussion_id), slug=discussion.slug) 

4211 assert res.community.community_id == community_id 

4212 assert res.community.community_name == "Discussion Community" 

4213 assert res.community.group_id == group_id 

4214 assert res.community.group_name == "Discussion Group" 

4215 assert not res.HasField("thread_parent") 

4216 

4217 

4218def test_GetModeratedObject_reference(db): 

4219 super_user, super_token = generate_user(is_superuser=True) 

4220 from_user, _ = generate_user() 

4221 to_user, _ = generate_user() 

4222 

4223 with session_scope() as session: 

4224 reference_id = create_friend_reference(session, from_user.id, to_user.id, timedelta(days=1)) 

4225 

4226 res = _get_moderated_object(super_token, _moderation_state_id(ModerationObjectType.reference, reference_id)) 

4227 assert res.moderation_state.author_user_id == from_user.id 

4228 assert res.url == urls.user_link(username=to_user.username) 

4229 assert res.involved_user_ids == [to_user.id] 

4230 assert not res.HasField("community") 

4231 

4232 

4233def test_GetModeratedObject_public_trip(db): 

4234 super_user, super_token = generate_user(is_superuser=True) 

4235 user, token = generate_user() 

4236 

4237 with session_scope() as session: 

4238 community_id = create_community(session, 0, 2, "Trip Community", [user], [], None).id 

4239 

4240 with public_trips_session(token) as api: 

4241 trip_id = api.CreatePublicTrip( 

4242 public_trips_pb2.CreatePublicTripReq( 

4243 community_id=community_id, 

4244 from_date=(today() + timedelta(days=5)).isoformat(), 

4245 to_date=(today() + timedelta(days=10)).isoformat(), 

4246 description=VALID_TRIP_DESCRIPTION, 

4247 ) 

4248 ).trip_id 

4249 

4250 res = _get_moderated_object(super_token, _moderation_state_id(ModerationObjectType.public_trip, trip_id)) 

4251 assert res.community.community_id == community_id 

4252 assert res.community.community_name == "Trip Community" 

4253 assert res.community.group_id == 0 

4254 assert res.url == "" 

4255 assert not res.involved_user_ids 

4256 

4257 

4258def test_GetModeratedObject_user(db): 

4259 super_user, super_token = generate_user(is_superuser=True) 

4260 user, _ = generate_user() 

4261 

4262 res = _get_moderated_object(super_token, _moderation_state_id(ModerationObjectType.user, user.id)) 

4263 assert res.moderation_state.author_user_id == user.id 

4264 assert res.url == urls.user_link(username=user.username) 

4265 assert not res.involved_user_ids 

4266 assert not res.HasField("community") 

4267 

4268 

4269def test_GetModeratedObject_event_occurrence_owned_by_user(db): 

4270 super_user, super_token = generate_user(is_superuser=True) 

4271 user, token = generate_user() 

4272 

4273 with session_scope() as session: 

4274 community_id = create_community(session, 0, 2, "Event Community", [user], [], None).id 

4275 

4276 event = events_api.create_event(token=token) 

4277 

4278 res = _get_moderated_object( 

4279 super_token, _moderation_state_id(ModerationObjectType.event_occurrence, event.event_id) 

4280 ) 

4281 assert res.url == urls.event_link(occurrence_id=event.event_id, slug=event.slug) 

4282 assert res.community.community_id == community_id 

4283 assert res.community.community_name == "Event Community" 

4284 assert res.community.group_id == 0 

4285 assert not res.HasField("thread_parent") 

4286 

4287 

4288def test_GetModeratedObject_event_occurrence_transferred_to_community(db): 

4289 super_user, super_token = generate_user(is_superuser=True) 

4290 user, token = generate_user() 

4291 

4292 with session_scope() as session: 

4293 create_community(session, 0, 2, "Location Community", [user], [], None) 

4294 other_community_id = create_community(session, 5, 7, "Other Community", [user], [], None).id 

4295 

4296 event = events_api.create_event(token=token) 

4297 events_api.transfer_event(token=token, event_id=event.event_id, new_owner_community_id=other_community_id) 

4298 

4299 res = _get_moderated_object( 

4300 super_token, _moderation_state_id(ModerationObjectType.event_occurrence, event.event_id) 

4301 ) 

4302 assert res.community.community_id == other_community_id 

4303 assert res.community.community_name == "Other Community" 

4304 assert res.community.group_id == 0 

4305 

4306 

4307def test_GetModeratedObject_not_found(db): 

4308 super_user, super_token = generate_user(is_superuser=True) 

4309 

4310 with real_moderation_session(super_token) as api: 

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

4312 api.GetModeratedObject(moderation_pb2.GetModeratedObjectReq(moderation_state_id=999_999_999)) 

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