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

1638 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 15:47 +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 

11from sqlalchemy.exc import IntegrityError 

12from sqlalchemy.sql import select 

13 

14from couchers.config import config 

15from couchers.constants import MODERATION_AUTO_APPROVE_FLAG_PRIORITY 

16from couchers.db import session_scope 

17from couchers.jobs.handlers import auto_approve_moderation_queue 

18from couchers.jobs.worker import process_job 

19from couchers.models import ( 

20 AdminAction, 

21 EventOccurrence, 

22 FriendRelationship, 

23 GroupChat, 

24 HostRequest, 

25 ModerationAction, 

26 ModerationLog, 

27 ModerationObjectType, 

28 ModerationQueueItem, 

29 ModerationState, 

30 ModerationTrigger, 

31 ModerationVisibility, 

32 User, 

33 get_moderated_models, 

34) 

35from couchers.moderation.utils import create_moderation 

36from couchers.proto import ( 

37 api_pb2, 

38 conversations_pb2, 

39 events_pb2, 

40 messages_pb2, 

41 moderation_pb2, 

42 notifications_pb2, 

43 requests_pb2, 

44) 

45from couchers.utils import Timestamp_from_datetime, datetime_to_iso8601_local, now, today 

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

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

48from tests.fixtures.sessions import ( 

49 api_session, 

50 conversations_session, 

51 events_session, 

52 notifications_session, 

53 real_moderation_session, 

54 requests_session, 

55) 

56from tests.test_communities import create_community 

57from tests.test_requests import valid_request_text 

58 

59 

60def create_test_host_request_with_moderation(surfer_token, host_user_id): 

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

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

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

64 

65 with requests_session(surfer_token) as api: 

66 hr_id = api.CreateHostRequest( 

67 requests_pb2.CreateHostRequestReq( 

68 host_user_id=host_user_id, 

69 from_date=today_plus_2, 

70 to_date=today_plus_3, 

71 text=valid_request_text(), 

72 ) 

73 ).host_request_id 

74 

75 backdate_conversations() 

76 

77 with session_scope() as session: 

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

79 return hr.moderation_state_id 

80 

81 

82# ============================================================================ 

83# Tests for moderation helper functions 

84# ============================================================================ 

85 

86 

87def test_create_moderation(db): 

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

89 user, _ = generate_user() 

90 

91 with session_scope() as session: 

92 # Create a moderation state 

93 moderation_state = create_moderation( 

94 session=session, 

95 object_type=ModerationObjectType.host_request, 

96 object_id=123, 

97 creator_user_id=user.id, 

98 ) 

99 

100 assert moderation_state.object_type == ModerationObjectType.host_request 

101 assert moderation_state.object_id == 123 

102 assert moderation_state.visibility == ModerationVisibility.shadowed 

103 

104 # Check that log entry was created 

105 log_entries = ( 

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

107 .scalars() 

108 .all() 

109 ) 

110 

111 assert len(log_entries) == 1 

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

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

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

115 

116 

117def test_add_to_moderation_queue(db): 

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

119 super_user, super_token = generate_user(is_superuser=True) 

120 user1, token1 = generate_user() 

121 user2, _ = generate_user() 

122 

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

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

125 

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

127 with requests_session(token1) as api: 

128 host_request_id = api.CreateHostRequest( 

129 requests_pb2.CreateHostRequestReq( 

130 host_user_id=user2.id, 

131 from_date=today_plus_2, 

132 to_date=today_plus_3, 

133 text=valid_request_text(), 

134 ) 

135 ).host_request_id 

136 

137 # Get the moderation state ID 

138 state_id = None 

139 with session_scope() as session: 

140 host_request = session.execute( 

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

142 ).scalar_one() 

143 state_id = host_request.moderation_state_id 

144 

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

146 with real_moderation_session(super_token) as api: 

147 api.ModerateContent( 

148 moderation_pb2.ModerateContentReq( 

149 moderation_state_id=state_id, 

150 action=moderation_pb2.MODERATION_ACTION_FLAG, 

151 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

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

153 ) 

154 ) 

155 

156 with session_scope() as session: 

157 flag = session.execute( 

158 select(ModerationQueueItem) 

159 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

161 ).scalar_one() 

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

163 assert flag.resolved_by_log_id is None 

164 

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

166 flag_log = session.execute( 

167 select(ModerationLog) 

168 .where(ModerationLog.moderation_state_id == state_id) 

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

170 ).scalar_one() 

171 assert flag_log.queue_item_id == flag.id 

172 

173 

174def test_flag_with_data(db): 

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

176 super_user, super_token = generate_user(is_superuser=True) 

177 user1, token1 = generate_user() 

178 user2, _ = generate_user() 

179 

180 state_id = create_test_host_request_with_moderation(token1, user2.id) 

181 

182 with real_moderation_session(super_token) as api: 

183 api.ModerateContent( 

184 moderation_pb2.ModerateContentReq( 

185 moderation_state_id=state_id, 

186 action=moderation_pb2.MODERATION_ACTION_FLAG, 

187 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG, 

188 reason="Automod", 

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

190 ) 

191 ) 

192 

193 res = api.GetModerationQueue( 

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

195 ) 

196 assert len(res.queue_items) == 1 

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

198 "classifier": "spam", 

199 "score": 0.97, 

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

201 } 

202 

203 with session_scope() as session: 

204 flag = session.execute( 

205 select(ModerationQueueItem) 

206 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

208 ).scalar_one() 

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

210 

211 

212def test_flag_without_data(db): 

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

214 super_user, super_token = generate_user(is_superuser=True) 

215 user1, token1 = generate_user() 

216 user2, _ = generate_user() 

217 

218 state_id = create_test_host_request_with_moderation(token1, user2.id) 

219 

220 with real_moderation_session(super_token) as api: 

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

222 assert len(res.queue_items) == 1 

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

224 

225 with session_scope() as session: 

226 item = session.execute( 

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

228 ).scalar_one() 

229 assert item.data is None 

230 

231 

232def test_flag_with_invalid_data(db): 

233 """Flag data must be valid JSON""" 

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 with pytest.raises(grpc.RpcError) as e: 

242 api.ModerateContent( 

243 moderation_pb2.ModerateContentReq( 

244 moderation_state_id=state_id, 

245 action=moderation_pb2.MODERATION_ACTION_FLAG, 

246 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG, 

247 reason="Automod", 

248 data="not json", 

249 ) 

250 ) 

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

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

253 

254 

255def test_data_rejected_on_non_flag_action(db): 

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

257 super_user, super_token = generate_user(is_superuser=True) 

258 user1, token1 = generate_user() 

259 user2, _ = generate_user() 

260 

261 state_id = create_test_host_request_with_moderation(token1, user2.id) 

262 

263 with real_moderation_session(super_token) as api: 

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

265 api.ModerateContent( 

266 moderation_pb2.ModerateContentReq( 

267 moderation_state_id=state_id, 

268 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

269 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

270 reason="Looks fine", 

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

272 ) 

273 ) 

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

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

276 

277 

278def test_create_moderation_with_data(db): 

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

280 user, _ = generate_user() 

281 

282 with session_scope() as session: 

283 moderation_state = create_moderation( 

284 session=session, 

285 object_type=ModerationObjectType.host_request, 

286 object_id=123, 

287 creator_user_id=user.id, 

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

289 ) 

290 

291 item = session.execute( 

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

293 ).scalar_one() 

294 assert item.trigger == ModerationTrigger.initial_review 

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

296 

297 

298def test_moderate_content(db): 

299 """Test moderating content via API""" 

300 super_user, super_token = generate_user(is_superuser=True) 

301 user, token = generate_user() 

302 host, _ = generate_user() 

303 

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

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

306 

307 # Create a real host request 

308 state_id = None 

309 with requests_session(token) as api: 

310 hr_id = api.CreateHostRequest( 

311 requests_pb2.CreateHostRequestReq( 

312 host_user_id=host.id, 

313 from_date=today_plus_2, 

314 to_date=today_plus_3, 

315 text=valid_request_text(), 

316 ) 

317 ).host_request_id 

318 

319 with session_scope() as session: 

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

321 state_id = hr.moderation_state_id 

322 

323 # Moderate the content via API 

324 with real_moderation_session(super_token) as api: 

325 res = api.ModerateContent( 

326 moderation_pb2.ModerateContentReq( 

327 moderation_state_id=state_id, 

328 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

329 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

330 reason="Content looks good", 

331 ) 

332 ) 

333 

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

335 

336 # Check that state was updated in database 

337 with session_scope() as session: 

338 updated_state = session.get_one(ModerationState, state_id) 

339 assert updated_state.visibility == ModerationVisibility.visible 

340 

341 # Check that log entry was created 

342 log_entries = ( 

343 session.execute( 

344 select(ModerationLog) 

345 .where(ModerationLog.moderation_state_id == state_id) 

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

347 ) 

348 .scalars() 

349 .all() 

350 ) 

351 

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

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

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

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

356 

357 

358def test_resolve_queue_item(db): 

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

360 user1, token1 = generate_user() 

361 user2, _ = generate_user() 

362 moderator, moderator_token = generate_user(is_superuser=True) 

363 

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

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

366 

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

368 with requests_session(token1) as api: 

369 host_request_id = api.CreateHostRequest( 

370 requests_pb2.CreateHostRequestReq( 

371 host_user_id=user2.id, 

372 from_date=today_plus_2, 

373 to_date=today_plus_3, 

374 text=valid_request_text(), 

375 ) 

376 ).host_request_id 

377 

378 state_id = None 

379 with session_scope() as session: 

380 # Get the host request and its moderation state 

381 host_request = session.execute( 

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

383 ).scalar_one() 

384 state_id = host_request.moderation_state_id 

385 

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

387 queue_item = session.execute( 

388 select(ModerationQueueItem) 

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

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

391 ).scalar_one() 

392 

393 assert queue_item.resolved_by_log_id is None 

394 

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

396 with real_moderation_session(moderator_token) as api: 

397 api.ModerateContent( 

398 moderation_pb2.ModerateContentReq( 

399 moderation_state_id=state_id, 

400 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

401 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

402 reason="Approved after review", 

403 clear_flags=True, 

404 ) 

405 ) 

406 

407 # Check that queue item was resolved 

408 with session_scope() as session: 

409 queue_item = session.execute( 

410 select(ModerationQueueItem) 

411 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

413 ).scalar_one() 

414 assert queue_item.resolved_by_log_id is not None 

415 

416 

417def test_approve_content_via_api(db): 

418 """Test approving content via ModerateContent API""" 

419 user1, token1 = generate_user() 

420 user2, _ = generate_user() 

421 moderator, moderator_token = generate_user(is_superuser=True) 

422 

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

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

425 

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

427 with requests_session(token1) as api: 

428 host_request_id = api.CreateHostRequest( 

429 requests_pb2.CreateHostRequestReq( 

430 host_user_id=user2.id, 

431 from_date=today_plus_2, 

432 to_date=today_plus_3, 

433 text=valid_request_text(), 

434 ) 

435 ).host_request_id 

436 

437 state_id = None 

438 with session_scope() as session: 

439 # Get the host request and its moderation state 

440 host_request = session.execute( 

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

442 ).scalar_one() 

443 state_id = host_request.moderation_state_id 

444 

445 # Approve via API 

446 with real_moderation_session(moderator_token) as api: 

447 api.ModerateContent( 

448 moderation_pb2.ModerateContentReq( 

449 moderation_state_id=state_id, 

450 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

451 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

452 reason="Quick approval", 

453 ) 

454 ) 

455 

456 # Check that state was updated to VISIBLE 

457 with session_scope() as session: 

458 updated_state = session.get_one(ModerationState, state_id) 

459 assert updated_state.visibility == ModerationVisibility.visible 

460 

461 # Check log entry 

462 log_entry = session.execute( 

463 select(ModerationLog) 

464 .where(ModerationLog.moderation_state_id == state_id) 

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

466 ).scalar_one() 

467 

468 assert log_entry.moderator_user_id == moderator.id 

469 assert log_entry.reason == "Quick approval" 

470 

471 

472# ============================================================================ 

473# Tests for host request moderation integration 

474# ============================================================================ 

475 

476 

477def test_create_host_request_creates_moderation_state(db): 

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

479 user1, token1 = generate_user() 

480 user2, token2 = generate_user() 

481 

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

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

484 

485 with requests_session(token1) as api: 

486 host_request_id = api.CreateHostRequest( 

487 requests_pb2.CreateHostRequestReq( 

488 host_user_id=user2.id, 

489 from_date=today_plus_2, 

490 to_date=today_plus_3, 

491 text=valid_request_text(), 

492 ) 

493 ).host_request_id 

494 

495 with session_scope() as session: 

496 # Check that host request has a moderation state 

497 host_request = session.execute( 

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

499 ).scalar_one() 

500 

501 # Check moderation state properties 

502 moderation_state = session.execute( 

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

504 ).scalar_one() 

505 

506 assert moderation_state.object_type == ModerationObjectType.host_request 

507 assert moderation_state.object_id == host_request_id 

508 assert moderation_state.visibility == ModerationVisibility.shadowed 

509 

510 # Check that it was added to moderation queue 

511 queue_items = ( 

512 session.execute( 

513 select(ModerationQueueItem) 

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

515 .where(ModerationQueueItem.resolved_by_log_id == None) 

516 ) 

517 .scalars() 

518 .all() 

519 ) 

520 

521 assert len(queue_items) == 1 

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

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

524 

525 

526def test_host_request_no_notification_before_approval(db, push_collector: PushCollector): 

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

528 user1, token1 = generate_user() 

529 user2, token2 = generate_user() 

530 

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

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

533 

534 with requests_session(token1) as api: 

535 host_request_id = api.CreateHostRequest( 

536 requests_pb2.CreateHostRequestReq( 

537 host_user_id=user2.id, 

538 from_date=today_plus_2, 

539 to_date=today_plus_3, 

540 text=valid_request_text(), 

541 ) 

542 ).host_request_id 

543 

544 # Process all jobs (including the notification job) 

545 process_jobs() 

546 

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

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

549 

550 

551def test_shadowed_notification_not_in_list_notifications(db): 

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

553 user1, token1 = generate_user() 

554 user2, token2 = generate_user() 

555 

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

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

558 

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

560 with requests_session(token1) as api: 

561 host_request_id = api.CreateHostRequest( 

562 requests_pb2.CreateHostRequestReq( 

563 host_user_id=user2.id, 

564 from_date=today_plus_2, 

565 to_date=today_plus_3, 

566 text=valid_request_text(), 

567 ) 

568 ).host_request_id 

569 

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

571 with notifications_session(token2) as api: 

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

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

574 assert len(res.notifications) == 0 

575 

576 

577def test_notification_visible_after_approval(db): 

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

579 user1, token1 = generate_user() 

580 user2, token2 = generate_user() 

581 mod, mod_token = generate_user(is_superuser=True) 

582 

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

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

585 

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

587 with requests_session(token1) as api: 

588 host_request_id = api.CreateHostRequest( 

589 requests_pb2.CreateHostRequestReq( 

590 host_user_id=user2.id, 

591 from_date=today_plus_2, 

592 to_date=today_plus_3, 

593 text=valid_request_text(), 

594 ) 

595 ).host_request_id 

596 

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

598 with notifications_session(token2) as api: 

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

600 assert len(res.notifications) == 0 

601 

602 # Get the moderation state ID and approve 

603 with session_scope() as session: 

604 host_request = session.execute( 

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

606 ).scalar_one() 

607 state_id = host_request.moderation_state_id 

608 

609 with real_moderation_session(mod_token) as api: 

610 api.ModerateContent( 

611 moderation_pb2.ModerateContentReq( 

612 moderation_state_id=state_id, 

613 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

614 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

615 reason="Looks good", 

616 ) 

617 ) 

618 

619 # Now host SHOULD see the notification 

620 with notifications_session(token2) as api: 

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

622 assert len(res.notifications) == 1 

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

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

625 

626 

627def test_shadowed_host_request_visible_to_author_only(db): 

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

629 user1, token1 = generate_user() 

630 user2, token2 = generate_user() 

631 

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

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

634 

635 with requests_session(token1) as api: 

636 host_request_id = api.CreateHostRequest( 

637 requests_pb2.CreateHostRequestReq( 

638 host_user_id=user2.id, 

639 from_date=today_plus_2, 

640 to_date=today_plus_3, 

641 text=valid_request_text(), 

642 ) 

643 ).host_request_id 

644 

645 # Surfer (author) can see it with GetHostRequest 

646 with requests_session(token1) as api: 

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

648 assert res.host_request_id == host_request_id 

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

650 

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

652 with requests_session(token2) as api: 

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

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

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

656 

657 

658def test_unlisted_host_request_not_in_lists(db): 

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

660 user1, token1 = generate_user() 

661 user2, token2 = generate_user() 

662 

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

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

665 

666 with requests_session(token1) as api: 

667 host_request_id = api.CreateHostRequest( 

668 requests_pb2.CreateHostRequestReq( 

669 host_user_id=user2.id, 

670 from_date=today_plus_2, 

671 to_date=today_plus_3, 

672 text=valid_request_text(), 

673 ) 

674 ).host_request_id 

675 

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

677 with requests_session(token1) as api: 

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

679 assert len(res.host_requests) == 1 

680 

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

682 with requests_session(token2) as api: 

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

684 assert len(res.host_requests) == 0 

685 

686 

687def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector): 

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

689 user1, token1 = generate_user() 

690 user2, token2 = generate_user() 

691 mod, mod_token = generate_user(is_superuser=True) 

692 

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

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

695 

696 with requests_session(token1) as api: 

697 host_request_id = api.CreateHostRequest( 

698 requests_pb2.CreateHostRequestReq( 

699 host_user_id=user2.id, 

700 from_date=today_plus_2, 

701 to_date=today_plus_3, 

702 text=valid_request_text(), 

703 ) 

704 ).host_request_id 

705 

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

707 process_jobs() 

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

709 

710 # Get the moderation state ID 

711 state_id = None 

712 with session_scope() as session: 

713 host_request = session.execute( 

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

715 ).scalar_one() 

716 state_id = host_request.moderation_state_id 

717 

718 # Approve the host request via API 

719 with real_moderation_session(mod_token) as api: 

720 api.ModerateContent( 

721 moderation_pb2.ModerateContentReq( 

722 moderation_state_id=state_id, 

723 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

724 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

725 reason="Looks good", 

726 ) 

727 ) 

728 

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

730 process_jobs() 

731 

732 # Now surfer SHOULD see it in their sent list 

733 with requests_session(token1) as api: 

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

735 assert len(res.host_requests) == 1 

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

737 

738 # Host SHOULD see it in their received list 

739 with requests_session(token2) as api: 

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

741 assert len(res.host_requests) == 1 

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

743 

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

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

746 

747 

748def test_hidden_host_request_invisible_to_all(db): 

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

750 user1, token1 = generate_user() 

751 user2, token2 = generate_user() 

752 user3, token3 = generate_user() # Third party 

753 moderator, moderator_token = generate_user(is_superuser=True) 

754 

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

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

757 

758 with requests_session(token1) as api: 

759 host_request_id = api.CreateHostRequest( 

760 requests_pb2.CreateHostRequestReq( 

761 host_user_id=user2.id, 

762 from_date=today_plus_2, 

763 to_date=today_plus_3, 

764 text=valid_request_text(), 

765 ) 

766 ).host_request_id 

767 

768 # Get the moderation state ID 

769 state_id = None 

770 with session_scope() as session: 

771 host_request = session.execute( 

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

773 ).scalar_one() 

774 state_id = host_request.moderation_state_id 

775 

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

777 with real_moderation_session(moderator_token) as api: 

778 api.ModerateContent( 

779 moderation_pb2.ModerateContentReq( 

780 moderation_state_id=state_id, 

781 action=moderation_pb2.MODERATION_ACTION_HIDE, 

782 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

783 reason="Spam content", 

784 ) 

785 ) 

786 

787 # Surfer can't see it with GetHostRequest 

788 with requests_session(token1) as api: 

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

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

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

792 

793 # Host can't see it with GetHostRequest 

794 with requests_session(token2) as api: 

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

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

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

798 

799 # Third party definitely can't see it 

800 with requests_session(token3) as api: 

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

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

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

804 

805 # Not in any lists 

806 with requests_session(token1) as api: 

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

808 assert len(res.host_requests) == 0 

809 

810 with requests_session(token2) as api: 

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

812 assert len(res.host_requests) == 0 

813 

814 

815def test_multiple_host_requests_listing_visibility(db): 

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

817 user1, token1 = generate_user() 

818 user2, token2 = generate_user() 

819 moderator, moderator_token = generate_user(is_superuser=True) 

820 

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

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

823 

824 # Create 3 host requests 

825 host_request_ids = [] 

826 state_ids = [] 

827 with requests_session(token1) as api: 

828 for i in range(3): 

829 hr_id = api.CreateHostRequest( 

830 requests_pb2.CreateHostRequestReq( 

831 host_user_id=user2.id, 

832 from_date=today_plus_2, 

833 to_date=today_plus_3, 

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

835 ) 

836 ).host_request_id 

837 host_request_ids.append(hr_id) 

838 backdate_conversations() 

839 

840 # Get state IDs 

841 with session_scope() as session: 

842 for hr_id in host_request_ids: 

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

844 state_ids.append(host_request.moderation_state_id) 

845 

846 # Approve the first one via API 

847 with real_moderation_session(moderator_token) as api: 

848 api.ModerateContent( 

849 moderation_pb2.ModerateContentReq( 

850 moderation_state_id=state_ids[0], 

851 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

852 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

853 reason="Approved", 

854 ) 

855 ) 

856 

857 # Hide the third one via API 

858 with real_moderation_session(moderator_token) as api: 

859 api.ModerateContent( 

860 moderation_pb2.ModerateContentReq( 

861 moderation_state_id=state_ids[2], 

862 action=moderation_pb2.MODERATION_ACTION_HIDE, 

863 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

864 reason="Spam", 

865 ) 

866 ) 

867 

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

869 with requests_session(token1) as api: 

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

871 assert len(res.host_requests) == 2 

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

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

874 

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

876 with requests_session(token2) as api: 

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

878 assert len(res.host_requests) == 1 

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

880 

881 

882def test_moderation_log_tracking(db): 

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

884 user, user_token = generate_user() 

885 host, _ = generate_user() 

886 moderator1, moderator1_token = generate_user(is_superuser=True) 

887 moderator2, moderator2_token = generate_user(is_superuser=True) 

888 

889 # Create a real host request 

890 state_id = create_test_host_request_with_moderation(user_token, host.id) 

891 

892 # Perform several moderation actions via API 

893 with real_moderation_session(moderator1_token) as api: 

894 api.ModerateContent( 

895 moderation_pb2.ModerateContentReq( 

896 moderation_state_id=state_id, 

897 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

898 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

899 reason="Looks good initially", 

900 ) 

901 ) 

902 

903 with real_moderation_session(moderator2_token) as api: 

904 api.ModerateContent( 

905 moderation_pb2.ModerateContentReq( 

906 moderation_state_id=state_id, 

907 action=moderation_pb2.MODERATION_ACTION_FLAG, 

908 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

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

910 ) 

911 ) 

912 # Shadow it back 

913 api.ModerateContent( 

914 moderation_pb2.ModerateContentReq( 

915 moderation_state_id=state_id, 

916 action=moderation_pb2.MODERATION_ACTION_HIDE, 

917 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

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

919 ) 

920 ) 

921 

922 with real_moderation_session(moderator1_token) as api: 

923 api.ModerateContent( 

924 moderation_pb2.ModerateContentReq( 

925 moderation_state_id=state_id, 

926 action=moderation_pb2.MODERATION_ACTION_HIDE, 

927 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

928 reason="Actually it's spam", 

929 ) 

930 ) 

931 

932 # Check all log entries 

933 with session_scope() as session: 

934 log_entries = ( 

935 session.execute( 

936 select(ModerationLog) 

937 .where(ModerationLog.moderation_state_id == state_id) 

938 .order_by(ModerationLog.time.asc()) 

939 ) 

940 .scalars() 

941 .all() 

942 ) 

943 

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

945 assert len(log_entries) >= 3 

946 

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

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

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

950 

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

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

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

954 

955 # The last action should be hiding 

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

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

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

959 

960 

961def test_moderation_queue_workflow(db): 

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

963 user1, token1 = generate_user() 

964 user2, _ = generate_user() 

965 moderator, moderator_token = generate_user(is_superuser=True) 

966 

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

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

969 

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

971 with requests_session(token1) as api: 

972 host_request_id = api.CreateHostRequest( 

973 requests_pb2.CreateHostRequestReq( 

974 host_user_id=user2.id, 

975 from_date=today_plus_2, 

976 to_date=today_plus_3, 

977 text=valid_request_text(), 

978 ) 

979 ).host_request_id 

980 

981 state_id = None 

982 queue_item_id = None 

983 with session_scope() as session: 

984 # Get the host request and its moderation state 

985 host_request = session.execute( 

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

987 ).scalar_one() 

988 state_id = host_request.moderation_state_id 

989 

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

991 queue_item = session.execute( 

992 select(ModerationQueueItem) 

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

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

995 ).scalar_one() 

996 queue_item_id = queue_item.id 

997 

998 # Verify it's in the queue 

999 unresolved_items = ( 

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

1001 .scalars() 

1002 .all() 

1003 ) 

1004 

1005 assert len(unresolved_items) >= 1 

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

1007 

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

1009 with real_moderation_session(moderator_token) as api: 

1010 api.ModerateContent( 

1011 moderation_pb2.ModerateContentReq( 

1012 moderation_state_id=state_id, 

1013 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1014 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1015 reason="Content approved", 

1016 clear_flags=True, 

1017 ) 

1018 ) 

1019 

1020 # Verify queue item was resolved 

1021 with session_scope() as session: 

1022 # Verify it's no longer in unresolved queue 

1023 unresolved_items = ( 

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

1025 .scalars() 

1026 .all() 

1027 ) 

1028 

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

1030 

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

1032 queue_item = session.get_one(ModerationQueueItem, queue_item_id) 

1033 assert queue_item.resolved_by_log_id is not None 

1034 

1035 

1036# ============================================================================ 

1037# Moderation API Tests (testing the gRPC servicer) 

1038# ============================================================================ 

1039 

1040 

1041def test_GetModerationQueue_empty(db): 

1042 """Test getting an empty moderation queue""" 

1043 super_user, super_token = generate_user(is_superuser=True) 

1044 

1045 with real_moderation_session(super_token) as api: 

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

1047 assert len(res.queue_items) == 0 

1048 assert res.next_page_token == "" 

1049 

1050 

1051def test_GetModerationQueue_with_items(db): 

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

1053 super_user, super_token = generate_user(is_superuser=True) 

1054 normal_user, user_token = generate_user() 

1055 host, _ = generate_user() 

1056 

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

1058 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1059 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1060 

1061 with real_moderation_session(super_token) as api: 

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

1063 assert len(res.queue_items) == 2 

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

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

1066 

1067 

1068def test_GetModerationQueue_filter_by_trigger(db): 

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

1070 super_user, super_token = generate_user(is_superuser=True) 

1071 normal_user, user_token = generate_user() 

1072 host, _ = generate_user() 

1073 

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

1075 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1076 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1077 

1078 # Add USER_FLAG trigger to second item via API 

1079 with real_moderation_session(super_token) as api: 

1080 api.ModerateContent( 

1081 moderation_pb2.ModerateContentReq( 

1082 moderation_state_id=state2_id, 

1083 action=moderation_pb2.MODERATION_ACTION_FLAG, 

1084 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

1085 reason="Reported by user", 

1086 ) 

1087 ) 

1088 

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

1090 with real_moderation_session(super_token) as api: 

1091 res = api.GetModerationQueue( 

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

1093 ) 

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

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

1096 

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

1098 with real_moderation_session(super_token) as api: 

1099 res = api.GetModerationQueue( 

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

1101 ) 

1102 assert len(res.queue_items) == 1 

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

1104 

1105 

1106def test_GetModerationQueue_filter_created_before(db): 

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

1108 super_user, super_token = generate_user(is_superuser=True) 

1109 normal_user, user_token = generate_user() 

1110 host, _ = generate_user() 

1111 

1112 # Create host requests 

1113 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1114 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1115 

1116 # Backdate the first queue item 

1117 with session_scope() as session: 

1118 queue_item1 = session.execute( 

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

1120 ).scalar_one() 

1121 # Set it to 2 hours ago 

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

1123 

1124 # The second item remains at current time 

1125 

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

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

1128 with real_moderation_session(super_token) as api: 

1129 res = api.GetModerationQueue( 

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

1131 ) 

1132 assert len(res.queue_items) == 1 

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

1134 

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

1136 with real_moderation_session(super_token) as api: 

1137 res = api.GetModerationQueue( 

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

1139 ) 

1140 assert len(res.queue_items) == 2 

1141 

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

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

1144 with real_moderation_session(super_token) as api: 

1145 res = api.GetModerationQueue( 

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

1147 ) 

1148 assert len(res.queue_items) == 0 

1149 

1150 

1151def test_GetModerationQueue_filter_created_after(db): 

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

1153 super_user, super_token = generate_user(is_superuser=True) 

1154 normal_user, user_token = generate_user() 

1155 host, _ = generate_user() 

1156 

1157 # Create host requests 

1158 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1159 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1160 

1161 # Backdate the first queue item to 2 hours ago 

1162 with session_scope() as session: 

1163 queue_item1 = session.execute( 

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

1165 ).scalar_one() 

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

1167 

1168 # The second item remains at current time 

1169 

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

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

1172 with real_moderation_session(super_token) as api: 

1173 res = api.GetModerationQueue( 

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

1175 ) 

1176 assert len(res.queue_items) == 1 

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

1178 

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

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

1181 with real_moderation_session(super_token) as api: 

1182 res = api.GetModerationQueue( 

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

1184 ) 

1185 assert len(res.queue_items) == 2 

1186 

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

1188 with real_moderation_session(super_token) as api: 

1189 res = api.GetModerationQueue( 

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

1191 ) 

1192 assert len(res.queue_items) == 0 

1193 

1194 

1195def test_GetModerationQueue_filter_created_before_and_after(db): 

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

1197 super_user, super_token = generate_user(is_superuser=True) 

1198 normal_user, user_token = generate_user() 

1199 host, _ = generate_user() 

1200 

1201 # Create 3 host requests 

1202 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1203 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1204 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1205 

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

1207 with session_scope() as session: 

1208 queue_item1 = session.execute( 

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

1210 ).scalar_one() 

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

1212 

1213 queue_item2 = session.execute( 

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

1215 ).scalar_one() 

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

1217 

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

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

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

1221 with real_moderation_session(super_token) as api: 

1222 res = api.GetModerationQueue( 

1223 moderation_pb2.GetModerationQueueReq( 

1224 created_after=Timestamp_from_datetime(after_cutoff), 

1225 created_before=Timestamp_from_datetime(before_cutoff), 

1226 ) 

1227 ) 

1228 assert len(res.queue_items) == 1 

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

1230 

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

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

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

1234 with real_moderation_session(super_token) as api: 

1235 res = api.GetModerationQueue( 

1236 moderation_pb2.GetModerationQueueReq( 

1237 created_after=Timestamp_from_datetime(after_cutoff), 

1238 created_before=Timestamp_from_datetime(before_cutoff), 

1239 ) 

1240 ) 

1241 assert len(res.queue_items) == 1 

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

1243 

1244 

1245def test_GetModerationQueue_filter_unresolved(db): 

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

1247 super_user, super_token = generate_user(is_superuser=True) 

1248 normal_user, user_token = generate_user() 

1249 host, _ = generate_user() 

1250 

1251 # Create 2 host requests 

1252 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1253 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1254 

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

1256 with real_moderation_session(super_token) as api: 

1257 api.ModerateContent( 

1258 moderation_pb2.ModerateContentReq( 

1259 moderation_state_id=state1_id, 

1260 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1261 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1262 reason="Approved", 

1263 clear_flags=True, 

1264 ) 

1265 ) 

1266 

1267 # Get all items 

1268 with real_moderation_session(super_token) as api: 

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

1270 assert len(res.queue_items) == 2 

1271 

1272 # Get only unresolved items 

1273 with real_moderation_session(super_token) as api: 

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

1275 assert len(res.queue_items) == 1 

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

1277 

1278 

1279def test_GetModerationQueue_filter_by_author(db): 

1280 """Test filtering moderation queue by item_author_user_id""" 

1281 super_user, super_token = generate_user(is_superuser=True) 

1282 user1, token1 = generate_user() 

1283 user2, token2 = generate_user() 

1284 host_user, _ = generate_user() 

1285 

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

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

1288 

1289 # Create 2 host requests by user1 

1290 with requests_session(token1) as api: 

1291 hr1_id = api.CreateHostRequest( 

1292 requests_pb2.CreateHostRequestReq( 

1293 host_user_id=host_user.id, 

1294 from_date=today_plus_2, 

1295 to_date=today_plus_3, 

1296 text=valid_request_text(), 

1297 ) 

1298 ).host_request_id 

1299 backdate_conversations() 

1300 

1301 hr2_id = api.CreateHostRequest( 

1302 requests_pb2.CreateHostRequestReq( 

1303 host_user_id=host_user.id, 

1304 from_date=today_plus_2, 

1305 to_date=today_plus_3, 

1306 text=valid_request_text(), 

1307 ) 

1308 ).host_request_id 

1309 

1310 # Create 1 host request by user2 

1311 with requests_session(token2) as api: 

1312 hr3_id = api.CreateHostRequest( 

1313 requests_pb2.CreateHostRequestReq( 

1314 host_user_id=host_user.id, 

1315 from_date=today_plus_2, 

1316 to_date=today_plus_3, 

1317 text=valid_request_text(), 

1318 ) 

1319 ).host_request_id 

1320 

1321 # Get moderation state IDs 

1322 state1_id, state2_id, state3_id = None, None, None 

1323 with session_scope() as session: 

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

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

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

1327 state1_id = hr1.moderation_state_id 

1328 state2_id = hr2.moderation_state_id 

1329 state3_id = hr3.moderation_state_id 

1330 

1331 # Get all items (should be 3) 

1332 with real_moderation_session(super_token) as api: 

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

1334 assert len(res.queue_items) == 3 

1335 

1336 # Filter by user1 (should get 2) 

1337 with real_moderation_session(super_token) as api: 

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

1339 assert len(res.queue_items) == 2 

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

1341 

1342 # Filter by user2 (should get 1) 

1343 with real_moderation_session(super_token) as api: 

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

1345 assert len(res.queue_items) == 1 

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

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

1348 

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

1350 with real_moderation_session(super_token) as api: 

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

1352 assert len(res.queue_items) == 0 

1353 

1354 

1355def test_GetModerationQueue_ordering(db): 

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

1357 super_user, super_token = generate_user(is_superuser=True) 

1358 normal_user, user_token = generate_user() 

1359 host, _ = generate_user() 

1360 

1361 # Create 3 host requests 

1362 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1363 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1364 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1365 

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

1367 with session_scope() as session: 

1368 queue_item1 = session.execute( 

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

1370 ).scalar_one() 

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

1372 

1373 queue_item2 = session.execute( 

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

1375 ).scalar_one() 

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

1377 

1378 queue_item3 = session.execute( 

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

1380 ).scalar_one() 

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

1382 

1383 # Default order (oldest first) 

1384 with real_moderation_session(super_token) as api: 

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

1386 assert len(res.queue_items) == 3 

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

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

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

1390 

1391 # Explicit oldest first 

1392 with real_moderation_session(super_token) as api: 

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

1394 assert len(res.queue_items) == 3 

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

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

1397 

1398 # Newest first 

1399 with real_moderation_session(super_token) as api: 

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

1401 assert len(res.queue_items) == 3 

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

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

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

1405 

1406 

1407def test_GetModerationQueue_pagination_newest_first(db): 

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

1409 super_user, super_token = generate_user(is_superuser=True) 

1410 normal_user, normal_token = generate_user() 

1411 host_user, _ = generate_user() 

1412 

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

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

1415 

1416 # Create 5 host requests 

1417 hr_ids = [] 

1418 with requests_session(normal_token) as api: 

1419 for i in range(5): 

1420 hr_id = api.CreateHostRequest( 

1421 requests_pb2.CreateHostRequestReq( 

1422 host_user_id=host_user.id, 

1423 from_date=today_plus_2, 

1424 to_date=today_plus_3, 

1425 text=valid_request_text(), 

1426 ) 

1427 ).host_request_id 

1428 hr_ids.append(hr_id) 

1429 backdate_conversations() 

1430 

1431 # Get moderation state IDs 

1432 state_ids = [] 

1433 with session_scope() as session: 

1434 for hr_id in hr_ids: 

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

1436 state_ids.append(hr.moderation_state_id) 

1437 

1438 # Set different times so ordering is deterministic 

1439 with session_scope() as session: 

1440 for i, state_id in enumerate(state_ids): 

1441 queue_item = session.execute( 

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

1443 ).scalar_one() 

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

1445 

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

1447 with real_moderation_session(super_token) as api: 

1448 res1 = api.GetModerationQueue( 

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

1450 ) 

1451 assert len(res1.queue_items) == 2 

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

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

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

1455 assert res1.next_page_token # should have more pages 

1456 

1457 # Get second page using the token 

1458 res2 = api.GetModerationQueue( 

1459 moderation_pb2.GetModerationQueueReq( 

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

1461 ) 

1462 ) 

1463 assert len(res2.queue_items) == 2 

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

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

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

1467 

1468 # Pages should not overlap 

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

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

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

1472 

1473 

1474def test_GetModerationQueue_pagination_ignores_time_created(db): 

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

1476 super_user, super_token = generate_user(is_superuser=True) 

1477 normal_user, normal_token = generate_user() 

1478 host, _ = generate_user() 

1479 

1480 first_state_id = create_test_host_request_with_moderation(normal_token, host.id) 

1481 create_test_host_request_with_moderation(normal_token, host.id) 

1482 create_test_host_request_with_moderation(normal_token, host.id) 

1483 

1484 with session_scope() as session: 

1485 item = session.execute( 

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

1487 ).scalar_one() 

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

1489 

1490 paged_ids = [] 

1491 page_token = "" 

1492 with real_moderation_session(super_token) as api: 

1493 while True: 

1494 res = api.GetModerationQueue( 

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

1496 ) 

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

1498 page_token = res.next_page_token 

1499 if not page_token: 

1500 break 

1501 

1502 with session_scope() as session: 

1503 expected = list( 

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

1505 ) 

1506 

1507 assert paged_ids == expected 

1508 

1509 

1510def test_GetModerationLog(db): 

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

1512 super_user, super_token = generate_user(is_superuser=True) 

1513 moderator, moderator_token = generate_user(is_superuser=True) 

1514 normal_user, user_token = generate_user() 

1515 host, _ = generate_user() 

1516 

1517 # Create a real host request 

1518 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1519 

1520 # Perform a moderation action via API 

1521 with real_moderation_session(moderator_token) as api: 

1522 api.ModerateContent( 

1523 moderation_pb2.ModerateContentReq( 

1524 moderation_state_id=state_id, 

1525 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1526 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1527 reason="Looks good", 

1528 ) 

1529 ) 

1530 

1531 with real_moderation_session(super_token) as api: 

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

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

1534 assert res.moderation_state.moderation_state_id == state_id 

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

1536 # Log entries are in reverse chronological order 

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

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

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

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

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

1542 

1543 

1544def test_GetModerationLog_not_found(db): 

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

1546 super_user, super_token = generate_user(is_superuser=True) 

1547 

1548 with real_moderation_session(super_token) as api: 

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

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

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

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

1553 

1554 

1555def test_GetModerationState(db): 

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

1557 super_user, super_token = generate_user(is_superuser=True) 

1558 user1, token1 = generate_user() 

1559 user2, _ = generate_user() 

1560 

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

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

1563 

1564 with requests_session(token1) as api: 

1565 host_request_id = api.CreateHostRequest( 

1566 requests_pb2.CreateHostRequestReq( 

1567 host_user_id=user2.id, 

1568 from_date=today_plus_2, 

1569 to_date=today_plus_3, 

1570 text=valid_request_text(), 

1571 ) 

1572 ).host_request_id 

1573 

1574 with real_moderation_session(super_token) as api: 

1575 res = api.GetModerationState( 

1576 moderation_pb2.GetModerationStateReq( 

1577 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1578 object_id=host_request_id, 

1579 ) 

1580 ) 

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

1582 assert res.moderation_state.object_id == host_request_id 

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

1584 assert res.moderation_state.moderation_state_id > 0 

1585 

1586 

1587def test_GetModerationState_not_found(db): 

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

1589 super_user, super_token = generate_user(is_superuser=True) 

1590 

1591 with real_moderation_session(super_token) as api: 

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

1593 api.GetModerationState( 

1594 moderation_pb2.GetModerationStateReq( 

1595 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1596 object_id=999999, 

1597 ) 

1598 ) 

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

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

1601 

1602 

1603def test_GetModerationState_unspecified_type(db): 

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

1605 super_user, super_token = generate_user(is_superuser=True) 

1606 

1607 with real_moderation_session(super_token) as api: 

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

1609 api.GetModerationState( 

1610 moderation_pb2.GetModerationStateReq( 

1611 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED, 

1612 object_id=123, 

1613 ) 

1614 ) 

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

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

1617 

1618 

1619def test_ModerateContent_approve(db): 

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

1621 super_user, super_token = generate_user(is_superuser=True) 

1622 user1, token1 = generate_user() 

1623 user2, _ = generate_user() 

1624 

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

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

1627 

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

1629 with requests_session(token1) as api: 

1630 host_request_id = api.CreateHostRequest( 

1631 requests_pb2.CreateHostRequestReq( 

1632 host_user_id=user2.id, 

1633 from_date=today_plus_2, 

1634 to_date=today_plus_3, 

1635 text=valid_request_text(), 

1636 ) 

1637 ).host_request_id 

1638 

1639 # Get the moderation state ID 

1640 state_id = None 

1641 with session_scope() as session: 

1642 host_request = session.execute( 

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

1644 ).scalar_one() 

1645 state_id = host_request.moderation_state_id 

1646 

1647 with real_moderation_session(super_token) as api: 

1648 res = api.ModerateContent( 

1649 moderation_pb2.ModerateContentReq( 

1650 moderation_state_id=state_id, 

1651 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1652 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1653 reason="Approved by admin", 

1654 ) 

1655 ) 

1656 assert res.moderation_state.moderation_state_id == state_id 

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

1658 

1659 # Verify state was updated in database 

1660 with session_scope() as session: 

1661 state = session.get_one(ModerationState, state_id) 

1662 assert state.visibility == ModerationVisibility.visible 

1663 

1664 

1665def test_ModerateContent_not_found(db): 

1666 """Test moderating non-existent content""" 

1667 super_user, super_token = generate_user(is_superuser=True) 

1668 

1669 with real_moderation_session(super_token) as api: 

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

1671 api.ModerateContent( 

1672 moderation_pb2.ModerateContentReq( 

1673 moderation_state_id=999999, 

1674 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1675 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1676 reason="Test", 

1677 ) 

1678 ) 

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

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

1681 

1682 

1683def test_ModerateContent_hide(db): 

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

1685 super_user, super_token = generate_user(is_superuser=True) 

1686 normal_user, user_token = generate_user() 

1687 host, _ = generate_user() 

1688 

1689 # Create a real host request 

1690 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1691 

1692 with real_moderation_session(super_token) as api: 

1693 res = api.ModerateContent( 

1694 moderation_pb2.ModerateContentReq( 

1695 moderation_state_id=state_id, 

1696 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1697 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

1698 reason="Spam content", 

1699 ) 

1700 ) 

1701 assert res.moderation_state.moderation_state_id == state_id 

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

1703 

1704 # Verify state was updated in database 

1705 with session_scope() as session: 

1706 state = session.get_one(ModerationState, state_id) 

1707 assert state.visibility == ModerationVisibility.hidden 

1708 

1709 

1710def test_ModerateContent_shadow(db): 

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

1712 super_user, super_token = generate_user(is_superuser=True) 

1713 normal_user, user_token = generate_user() 

1714 host, _ = generate_user() 

1715 

1716 # Create a real host request 

1717 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1718 

1719 with real_moderation_session(super_token) as api: 

1720 res = api.ModerateContent( 

1721 moderation_pb2.ModerateContentReq( 

1722 moderation_state_id=state_id, 

1723 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1724 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

1725 reason="Needs further review", 

1726 ) 

1727 ) 

1728 assert res.moderation_state.moderation_state_id == state_id 

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

1730 

1731 # Verify state was updated in database 

1732 with session_scope() as session: 

1733 state = session.get_one(ModerationState, state_id) 

1734 assert state.visibility == ModerationVisibility.shadowed 

1735 

1736 

1737def test_ModerateContent_flag(db): 

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

1739 super_user, super_token = generate_user(is_superuser=True) 

1740 user1, token1 = generate_user() 

1741 user2, _ = generate_user() 

1742 

1743 # Create a host request 

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

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

1746 

1747 with requests_session(token1) as api: 

1748 host_request_id = api.CreateHostRequest( 

1749 requests_pb2.CreateHostRequestReq( 

1750 host_user_id=user2.id, 

1751 from_date=today_plus_2, 

1752 to_date=today_plus_3, 

1753 text=valid_request_text(), 

1754 ) 

1755 ).host_request_id 

1756 

1757 # Get the moderation state ID 

1758 with session_scope() as session: 

1759 host_request = session.execute( 

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

1761 ).scalar_one() 

1762 state_id = host_request.moderation_state_id 

1763 

1764 with real_moderation_session(super_token) as api: 

1765 api.ModerateContent( 

1766 moderation_pb2.ModerateContentReq( 

1767 moderation_state_id=state_id, 

1768 action=moderation_pb2.MODERATION_ACTION_FLAG, 

1769 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

1770 reason="Admin flagged for additional review", 

1771 priority=5, 

1772 ) 

1773 ) 

1774 

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

1776 with session_scope() as session: 

1777 queue_item = session.execute( 

1778 select(ModerationQueueItem) 

1779 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

1781 ).scalar_one() 

1782 assert queue_item.resolved_by_log_id is None 

1783 assert queue_item.priority == 5 

1784 

1785 flag_log = session.execute( 

1786 select(ModerationLog) 

1787 .where(ModerationLog.moderation_state_id == state_id) 

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

1789 ).scalar_one() 

1790 assert flag_log.queue_item_id == queue_item.id 

1791 

1792 

1793def test_ModerateContent_flag_requires_trigger(db): 

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

1795 super_user, super_token = generate_user(is_superuser=True) 

1796 user1, token1 = generate_user() 

1797 user2, _ = generate_user() 

1798 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1799 

1800 with real_moderation_session(super_token) as api: 

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

1802 api.ModerateContent( 

1803 moderation_pb2.ModerateContentReq( 

1804 moderation_state_id=state_id, 

1805 action=moderation_pb2.MODERATION_ACTION_FLAG, 

1806 reason="no trigger", 

1807 ) 

1808 ) 

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

1810 

1811 

1812def _open_queue_item_id(state_id): 

1813 with session_scope() as session: 

1814 return ( 

1815 session.execute( 

1816 select(ModerationQueueItem) 

1817 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

1819 ) 

1820 .scalars() 

1821 .one() 

1822 .id 

1823 ) 

1824 

1825 

1826def test_ModerateContent_set_priority(db): 

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

1828 moderator, mod_token = generate_user(is_superuser=True) 

1829 user1, token1 = generate_user() 

1830 user2, _ = generate_user() 

1831 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1832 queue_item_id = _open_queue_item_id(state_id) 

1833 

1834 with real_moderation_session(mod_token) as api: 

1835 api.ModerateContent( 

1836 moderation_pb2.ModerateContentReq( 

1837 moderation_state_id=state_id, 

1838 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY, 

1839 queue_item_id=queue_item_id, 

1840 priority=10, 

1841 reason="bump", 

1842 ) 

1843 ) 

1844 

1845 with session_scope() as session: 

1846 queue_item = session.get_one(ModerationQueueItem, queue_item_id) 

1847 assert queue_item.priority == 10 

1848 assert queue_item.resolved_by_log_id is None # not resolved 

1849 # State stays shadowed (visibility untouched) 

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

1851 

1852 log = session.execute( 

1853 select(ModerationLog) 

1854 .where(ModerationLog.moderation_state_id == state_id) 

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

1856 ).scalar_one() 

1857 assert log.new_priority == 10 

1858 assert log.queue_item_id == queue_item_id 

1859 

1860 # GetModerationQueue surfaces the new priority 

1861 with real_moderation_session(mod_token) as api: 

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

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

1864 assert item.priority == 10 

1865 

1866 

1867def test_ModerateContent_unflag(db): 

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

1869 moderator, mod_token = generate_user(is_superuser=True) 

1870 user1, token1 = generate_user() 

1871 user2, _ = generate_user() 

1872 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1873 queue_item_id = _open_queue_item_id(state_id) 

1874 

1875 with real_moderation_session(mod_token) as api: 

1876 api.ModerateContent( 

1877 moderation_pb2.ModerateContentReq( 

1878 moderation_state_id=state_id, 

1879 action=moderation_pb2.MODERATION_ACTION_UNFLAG, 

1880 queue_item_id=queue_item_id, 

1881 reason="dismissed", 

1882 ) 

1883 ) 

1884 

1885 with session_scope() as session: 

1886 queue_item = session.get_one(ModerationQueueItem, queue_item_id) 

1887 assert queue_item.resolved_by_log_id is not None 

1888 # Visibility unchanged (still shadowed from creation) 

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

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

1891 assert log.action == ModerationAction.unflag 

1892 assert log.queue_item_id == queue_item_id 

1893 

1894 

1895def test_ModerateContent_unflag_requires_queue_item(db): 

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

1897 moderator, mod_token = generate_user(is_superuser=True) 

1898 user1, token1 = generate_user() 

1899 user2, _ = generate_user() 

1900 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1901 

1902 with real_moderation_session(mod_token) as api: 

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

1904 api.ModerateContent( 

1905 moderation_pb2.ModerateContentReq( 

1906 moderation_state_id=state_id, 

1907 action=moderation_pb2.MODERATION_ACTION_UNFLAG, 

1908 reason="no target", 

1909 ) 

1910 ) 

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

1912 

1913 

1914def test_ModerateContent_approve_without_clear_flags_leaves_flag_open(db): 

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

1916 moderator, mod_token = generate_user(is_superuser=True) 

1917 user1, token1 = generate_user() 

1918 user2, _ = generate_user() 

1919 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1920 queue_item_id = _open_queue_item_id(state_id) 

1921 

1922 with real_moderation_session(mod_token) as api: 

1923 api.ModerateContent( 

1924 moderation_pb2.ModerateContentReq( 

1925 moderation_state_id=state_id, 

1926 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1927 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1928 reason="visible but still under review", 

1929 ) 

1930 ) 

1931 

1932 with session_scope() as session: 

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

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

1935 

1936 

1937def test_ModerateContent_flag_supersede(db): 

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

1939 moderator, mod_token = generate_user(is_superuser=True) 

1940 user1, token1 = generate_user() 

1941 user2, _ = generate_user() 

1942 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1943 initial_item_id = _open_queue_item_id(state_id) 

1944 

1945 with real_moderation_session(mod_token) as api: 

1946 api.ModerateContent( 

1947 moderation_pb2.ModerateContentReq( 

1948 moderation_state_id=state_id, 

1949 action=moderation_pb2.MODERATION_ACTION_FLAG, 

1950 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG, 

1951 priority=3, 

1952 supersede_queue_item_id=initial_item_id, 

1953 reason="machine re-flag", 

1954 ) 

1955 ) 

1956 

1957 with session_scope() as session: 

1958 # Old initial_review flag is resolved 

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

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

1961 open_items = ( 

1962 session.execute( 

1963 select(ModerationQueueItem) 

1964 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

1966 ) 

1967 .scalars() 

1968 .all() 

1969 ) 

1970 assert len(open_items) == 1 

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

1972 assert open_items[0].priority == 3 

1973 

1974 

1975def test_GetModerationQueue_filter_by_priority(db): 

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

1977 moderator, mod_token = generate_user(is_superuser=True) 

1978 user1, token1 = generate_user() 

1979 user2, _ = generate_user() 

1980 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1981 queue_item_id = _open_queue_item_id(state_id) 

1982 

1983 with real_moderation_session(mod_token) as api: 

1984 api.ModerateContent( 

1985 moderation_pb2.ModerateContentReq( 

1986 moderation_state_id=state_id, 

1987 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY, 

1988 queue_item_id=queue_item_id, 

1989 priority=7, 

1990 reason="raise", 

1991 ) 

1992 ) 

1993 

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

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

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

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

1998 

1999 # priority_max excludes it above the threshold 

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

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

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

2003 

2004 

2005# ============================================================================ 

2006# Tests for group chat moderation 

2007# ============================================================================ 

2008 

2009 

2010def test_group_chat_created_with_moderation_state(db): 

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

2012 user1, token1 = generate_user() 

2013 user2, _ = generate_user() 

2014 make_friends(user1, user2) 

2015 

2016 with conversations_session(token1) as api: 

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

2018 group_chat_id = res.group_chat_id 

2019 

2020 # Verify moderation state was created 

2021 with session_scope() as session: 

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

2023 

2024 assert group_chat.moderation_state.object_type == ModerationObjectType.group_chat 

2025 assert group_chat.moderation_state.object_id == group_chat_id 

2026 # Group chats start as SHADOWED 

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

2028 

2029 # A moderation queue item should have been created 

2030 queue_item = ( 

2031 session.execute( 

2032 select(ModerationQueueItem).where( 

2033 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id 

2034 ) 

2035 ) 

2036 .scalars() 

2037 .first() 

2038 ) 

2039 assert queue_item is not None 

2040 assert queue_item.trigger == ModerationTrigger.initial_review 

2041 

2042 

2043def test_group_chat_GetModerationState(db): 

2044 """Test GetModerationState API for group chats""" 

2045 user1, token1 = generate_user() 

2046 user2, _ = generate_user() 

2047 moderator, mod_token = generate_user(is_superuser=True) 

2048 make_friends(user1, user2) 

2049 

2050 with conversations_session(token1) as api: 

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

2052 group_chat_id = res.group_chat_id 

2053 

2054 # Moderator can look up the moderation state 

2055 with real_moderation_session(mod_token) as api: 

2056 res = api.GetModerationState( 

2057 moderation_pb2.GetModerationStateReq( 

2058 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

2059 object_id=group_chat_id, 

2060 ) 

2061 ) 

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

2063 assert res.moderation_state.object_id == group_chat_id 

2064 # Starts as SHADOWED 

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

2066 

2067 

2068def test_group_chat_moderation_hide(db): 

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

2070 user1, token1 = generate_user() 

2071 user2, token2 = generate_user() 

2072 moderator, mod_token = generate_user(is_superuser=True) 

2073 make_friends(user1, user2) 

2074 

2075 with conversations_session(token1) as api: 

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

2077 group_chat_id = res.group_chat_id 

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

2079 

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

2081 with real_moderation_session(mod_token) as api: 

2082 state_res = api.GetModerationState( 

2083 moderation_pb2.GetModerationStateReq( 

2084 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

2085 object_id=group_chat_id, 

2086 ) 

2087 ) 

2088 api.ModerateContent( 

2089 moderation_pb2.ModerateContentReq( 

2090 moderation_state_id=state_res.moderation_state.moderation_state_id, 

2091 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

2092 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

2093 reason="Approved", 

2094 ) 

2095 ) 

2096 

2097 # Both users can see the chat now 

2098 with conversations_session(token1) as api: 

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

2100 assert len(res.group_chats) == 1 

2101 

2102 with conversations_session(token2) as api: 

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

2104 assert len(res.group_chats) == 1 

2105 

2106 # Moderator hides the group chat 

2107 with real_moderation_session(mod_token) as api: 

2108 state_res = api.GetModerationState( 

2109 moderation_pb2.GetModerationStateReq( 

2110 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

2111 object_id=group_chat_id, 

2112 ) 

2113 ) 

2114 api.ModerateContent( 

2115 moderation_pb2.ModerateContentReq( 

2116 moderation_state_id=state_res.moderation_state.moderation_state_id, 

2117 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2118 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2119 reason="Inappropriate content", 

2120 ) 

2121 ) 

2122 

2123 # Neither user can see the chat now 

2124 with conversations_session(token1) as api: 

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

2126 assert len(res.group_chats) == 0 

2127 

2128 with conversations_session(token2) as api: 

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

2130 assert len(res.group_chats) == 0 

2131 

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

2133 with conversations_session(token1) as api: 

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

2135 assert len(res.messages) == 0 

2136 

2137 

2138def test_group_chat_moderation_shadow(db): 

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

2140 user1, token1 = generate_user() # Creator 

2141 user2, token2 = generate_user() # Participant 

2142 moderator, mod_token = generate_user(is_superuser=True) 

2143 make_friends(user1, user2) 

2144 

2145 with conversations_session(token1) as api: 

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

2147 group_chat_id = res.group_chat_id 

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

2149 

2150 # Moderator shadows the group chat 

2151 with real_moderation_session(mod_token) as api: 

2152 state_res = api.GetModerationState( 

2153 moderation_pb2.GetModerationStateReq( 

2154 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

2155 object_id=group_chat_id, 

2156 ) 

2157 ) 

2158 api.ModerateContent( 

2159 moderation_pb2.ModerateContentReq( 

2160 moderation_state_id=state_res.moderation_state.moderation_state_id, 

2161 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2162 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

2163 reason="Needs review", 

2164 ) 

2165 ) 

2166 

2167 # Creator can see SHADOWED content in list operations 

2168 with conversations_session(token1) as api: 

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

2170 assert len(res.group_chats) == 1 

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

2172 

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

2174 with conversations_session(token2) as api: 

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

2176 assert len(res.group_chats) == 0 

2177 

2178 # Creator can also access it directly via GetGroupChat 

2179 with conversations_session(token1) as api: 

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

2181 assert res.group_chat_id == group_chat_id 

2182 

2183 

2184# ============================================================================ 

2185# Tests for auto-approval background job 

2186# ============================================================================ 

2187 

2188 

2189def test_auto_approve_moderation_queue_disabled_when_zero(db, email_collector: EmailCollector): 

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

2191 moderator, mod_token = generate_user(is_superuser=True) 

2192 user1, token1 = generate_user() 

2193 user2, token2 = generate_user() 

2194 

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

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

2197 

2198 # Create a host request 

2199 with requests_session(token1) as api: 

2200 host_request_id = api.CreateHostRequest( 

2201 requests_pb2.CreateHostRequestReq( 

2202 host_user_id=user2.id, 

2203 from_date=today_plus_2, 

2204 to_date=today_plus_3, 

2205 text=valid_request_text(), 

2206 ) 

2207 ).host_request_id 

2208 

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

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

2211 

2212 # Ensure deadline is 0 (disabled) 

2213 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 0 

2214 

2215 # Run the job 

2216 auto_approve_moderation_queue(empty_pb2.Empty()) 

2217 

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

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

2220 assert res.host_request_id == host_request_id 

2221 

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

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

2224 assert len(res.host_requests) == 1 

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

2226 

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

2228 with requests_session(token2) as api: 

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

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

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

2232 

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

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

2235 assert len(res.host_requests) == 0 

2236 

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

2238 with real_moderation_session(mod_token) as api: 

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

2240 assert len(res.queue_items) == 1 

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

2242 

2243 # Moderator can check the state is still SHADOWED 

2244 state_res = api.GetModerationState( 

2245 moderation_pb2.GetModerationStateReq( 

2246 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2247 object_id=host_request_id, 

2248 ) 

2249 ) 

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

2251 

2252 

2253def test_auto_approve_moderation_queue_approves_old_items( 

2254 db, email_collector: EmailCollector, push_collector: PushCollector 

2255): 

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

2257 moderator, mod_token = generate_user(is_superuser=True) 

2258 user1, token1 = generate_user() 

2259 user2, token2 = generate_user() 

2260 

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

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

2263 

2264 # Create a host request 

2265 with requests_session(token1) as api: 

2266 host_request_id = api.CreateHostRequest( 

2267 requests_pb2.CreateHostRequestReq( 

2268 host_user_id=user2.id, 

2269 from_date=today_plus_2, 

2270 to_date=today_plus_3, 

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

2272 ) 

2273 ).host_request_id 

2274 

2275 # No email sent initially (shadowed) 

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

2277 

2278 # Host cannot see the request yet 

2279 with requests_session(token2) as api: 

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

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

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

2283 

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

2285 with session_scope() as session: 

2286 host_request = session.execute( 

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

2288 ).scalar_one() 

2289 queue_item = session.execute( 

2290 select(ModerationQueueItem) 

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

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

2293 ).scalar_one() 

2294 # Backdate the queue item by 2 minutes 

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

2296 

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

2298 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60 

2299 config.MODERATION_BOT_USER_ID = moderator.id 

2300 

2301 # Run the job 

2302 auto_approve_moderation_queue(empty_pb2.Empty()) 

2303 

2304 # Now host can see the request via API 

2305 with requests_session(token2) as api: 

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

2307 assert res.host_request_id == host_request_id 

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

2309 

2310 # Host sees it in their received list 

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

2312 assert len(res.host_requests) == 1 

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

2314 

2315 # Surfer sees it in their sent list 

2316 with requests_session(token1) as api: 

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

2318 assert len(res.host_requests) == 1 

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

2320 

2321 with real_moderation_session(mod_token) as api: 

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

2323 assert len(res.queue_items) == 1 

2324 flag_item = res.queue_items[0] 

2325 assert flag_item.trigger == moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG 

2326 assert flag_item.priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY 

2327 

2328 state_res = api.GetModerationState( 

2329 moderation_pb2.GetModerationStateReq( 

2330 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2331 object_id=host_request_id, 

2332 ) 

2333 ) 

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

2335 

2336 log_res = api.GetModerationLog( 

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

2338 ) 

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

2340 assert len(approve_entries) == 1 

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

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

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

2344 

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

2346 assert len(flag_entries) == 1 

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

2348 

2349 

2350def test_auto_approve_does_not_approve_recent_items(db, email_collector: EmailCollector): 

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

2352 moderator, mod_token = generate_user(is_superuser=True) 

2353 user1, token1 = generate_user() 

2354 user2, token2 = generate_user() 

2355 

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

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

2358 

2359 # Create a host request 

2360 with requests_session(token1) as api: 

2361 host_request_id = api.CreateHostRequest( 

2362 requests_pb2.CreateHostRequestReq( 

2363 host_user_id=user2.id, 

2364 from_date=today_plus_2, 

2365 to_date=today_plus_3, 

2366 text=valid_request_text(), 

2367 ) 

2368 ).host_request_id 

2369 

2370 # No email sent (shadowed) 

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

2372 

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

2374 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 3600 

2375 config.MODERATION_BOT_USER_ID = moderator.id 

2376 

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

2378 auto_approve_moderation_queue(empty_pb2.Empty()) 

2379 

2380 # Still no email sent 

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

2382 

2383 # Host still cannot see the request 

2384 with requests_session(token2) as api: 

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

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

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

2388 

2389 # Not in host's received list 

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

2391 assert len(res.host_requests) == 0 

2392 

2393 # Moderator sees it still in queue unresolved 

2394 with real_moderation_session(mod_token) as api: 

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

2396 assert len(res.queue_items) == 1 

2397 

2398 # State is still SHADOWED 

2399 state_res = api.GetModerationState( 

2400 moderation_pb2.GetModerationStateReq( 

2401 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2402 object_id=host_request_id, 

2403 ) 

2404 ) 

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

2406 

2407 

2408def test_auto_approve_does_not_approve_already_approved(db): 

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

2410 moderator, mod_token = generate_user(is_superuser=True) 

2411 user1, token1 = generate_user() 

2412 user2, token2 = generate_user() 

2413 

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

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

2416 

2417 # Create a host request 

2418 with requests_session(token1) as api: 

2419 host_request_id = api.CreateHostRequest( 

2420 requests_pb2.CreateHostRequestReq( 

2421 host_user_id=user2.id, 

2422 from_date=today_plus_2, 

2423 to_date=today_plus_3, 

2424 text=valid_request_text(), 

2425 ) 

2426 ).host_request_id 

2427 

2428 # Moderator approves it manually 

2429 with real_moderation_session(mod_token) as api: 

2430 state_res = api.GetModerationState( 

2431 moderation_pb2.GetModerationStateReq( 

2432 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2433 object_id=host_request_id, 

2434 ) 

2435 ) 

2436 state_id = state_res.moderation_state.moderation_state_id 

2437 

2438 api.ModerateContent( 

2439 moderation_pb2.ModerateContentReq( 

2440 moderation_state_id=state_id, 

2441 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

2442 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

2443 reason="Approved by moderator", 

2444 clear_flags=True, 

2445 ) 

2446 ) 

2447 

2448 # Host can now see it 

2449 with requests_session(token2) as api: 

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

2451 assert res.host_request_id == host_request_id 

2452 

2453 # Get log count before auto-approval 

2454 with real_moderation_session(mod_token) as api: 

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

2456 log_count_before = len(log_res_before.log_entries) 

2457 

2458 # Set deadline to 1 second 

2459 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1 

2460 config.MODERATION_BOT_USER_ID = moderator.id 

2461 

2462 # Run the job 

2463 auto_approve_moderation_queue(empty_pb2.Empty()) 

2464 

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

2466 with real_moderation_session(mod_token) as api: 

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

2468 assert len(log_res_after.log_entries) == log_count_before 

2469 

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

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

2472 assert len(queue_res.queue_items) == 0 

2473 

2474 

2475def test_auto_approve_does_not_approve_moderator_shadowed_items(db): 

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

2477 moderator, mod_token = generate_user(is_superuser=True) 

2478 user1, token1 = generate_user() 

2479 user2, token2 = generate_user() 

2480 

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

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

2483 

2484 # Create a host request 

2485 with requests_session(token1) as api: 

2486 host_request_id = api.CreateHostRequest( 

2487 requests_pb2.CreateHostRequestReq( 

2488 host_user_id=user2.id, 

2489 from_date=today_plus_2, 

2490 to_date=today_plus_3, 

2491 text=valid_request_text(), 

2492 ) 

2493 ).host_request_id 

2494 

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

2496 with real_moderation_session(mod_token) as api: 

2497 state_res = api.GetModerationState( 

2498 moderation_pb2.GetModerationStateReq( 

2499 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2500 object_id=host_request_id, 

2501 ) 

2502 ) 

2503 state_id = state_res.moderation_state.moderation_state_id 

2504 

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

2506 api.ModerateContent( 

2507 moderation_pb2.ModerateContentReq( 

2508 moderation_state_id=state_id, 

2509 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2510 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

2511 reason="Keeping shadowed for review", 

2512 clear_flags=True, 

2513 ) 

2514 ) 

2515 

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

2517 with session_scope() as session: 

2518 queue_item = session.execute( 

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

2520 ).scalar_one() 

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

2522 

2523 # Set deadline to 1 second 

2524 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1 

2525 config.MODERATION_BOT_USER_ID = moderator.id 

2526 

2527 # Get log count before 

2528 with real_moderation_session(mod_token) as api: 

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

2530 log_count_before = len(log_res_before.log_entries) 

2531 

2532 # Run the job 

2533 auto_approve_moderation_queue(empty_pb2.Empty()) 

2534 

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

2536 with real_moderation_session(mod_token) as api: 

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

2538 assert len(log_res_after.log_entries) == log_count_before 

2539 

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

2541 state_res = api.GetModerationState( 

2542 moderation_pb2.GetModerationStateReq( 

2543 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2544 object_id=host_request_id, 

2545 ) 

2546 ) 

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

2548 

2549 # Host still cannot see the request 

2550 with requests_session(token2) as api: 

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

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

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

2554 

2555 

2556def test_auto_approve_skips_shadowed_user_authored_items(db): 

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

2558 moderator, mod_token = generate_user(is_superuser=True) 

2559 surfer, surfer_token = generate_user() 

2560 host, host_token = generate_user() 

2561 

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

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

2564 

2565 with requests_session(surfer_token) as api: 

2566 host_request_id = api.CreateHostRequest( 

2567 requests_pb2.CreateHostRequestReq( 

2568 host_user_id=host.id, 

2569 from_date=today_plus_2, 

2570 to_date=today_plus_3, 

2571 text=valid_request_text(), 

2572 ) 

2573 ).host_request_id 

2574 

2575 # Shadow the surfer after the host request was created 

2576 with session_scope() as session: 

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

2578 

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

2580 with session_scope() as session: 

2581 host_request = session.execute( 

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

2583 ).scalar_one() 

2584 queue_item = session.execute( 

2585 select(ModerationQueueItem) 

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

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

2588 ).scalar_one() 

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

2590 

2591 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60 

2592 config.MODERATION_BOT_USER_ID = moderator.id 

2593 

2594 auto_approve_moderation_queue(empty_pb2.Empty()) 

2595 

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

2597 with real_moderation_session(mod_token) as api: 

2598 state_res = api.GetModerationState( 

2599 moderation_pb2.GetModerationStateReq( 

2600 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2601 object_id=host_request_id, 

2602 ) 

2603 ) 

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

2605 

2606 # Host still cannot see the request 

2607 with requests_session(host_token) as api: 

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

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

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

2611 

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

2613 with requests_session(surfer_token) as api: 

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

2615 assert res.host_request_id == host_request_id 

2616 

2617 

2618def test_auto_approve_preserves_other_open_flags(db): 

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

2620 moderator, mod_token = generate_user(is_superuser=True) 

2621 surfer, surfer_token = generate_user() 

2622 host, _ = generate_user() 

2623 

2624 state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

2625 

2626 with real_moderation_session(mod_token) as api: 

2627 api.ModerateContent( 

2628 moderation_pb2.ModerateContentReq( 

2629 moderation_state_id=state_id, 

2630 action=moderation_pb2.MODERATION_ACTION_FLAG, 

2631 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

2632 priority=5, 

2633 reason="user reported this", 

2634 ) 

2635 ) 

2636 

2637 with session_scope() as session: 

2638 initial_item = session.execute( 

2639 select(ModerationQueueItem) 

2640 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

2642 ).scalar_one() 

2643 initial_item_id = initial_item.id 

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

2645 

2646 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60 

2647 config.MODERATION_BOT_USER_ID = moderator.id 

2648 

2649 auto_approve_moderation_queue(empty_pb2.Empty()) 

2650 

2651 with session_scope() as session: 

2652 assert ( 

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

2654 .scalar_one() 

2655 .resolved_by_log_id 

2656 is not None 

2657 ) 

2658 assert ( 

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

2660 == ModerationVisibility.visible 

2661 ) 

2662 

2663 open_items = ( 

2664 session.execute( 

2665 select(ModerationQueueItem) 

2666 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

2668 ) 

2669 .scalars() 

2670 .all() 

2671 ) 

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

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

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

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

2676 

2677 

2678# ============================================================================ 

2679# Notification Suppression Tests 

2680# ============================================================================ 

2681 

2682 

2683def test_host_request_message_notifications_suppressed_before_approval( 

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

2685): 

2686 """ 

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

2688 that haven't been approved yet. 

2689 """ 

2690 host, host_token = generate_user(complete_profile=True) 

2691 surfer, surfer_token = generate_user(complete_profile=True) 

2692 

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

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

2695 

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

2697 with requests_session(surfer_token) as api: 

2698 hr_id = api.CreateHostRequest( 

2699 requests_pb2.CreateHostRequestReq( 

2700 host_user_id=host.id, 

2701 from_date=today_plus_2, 

2702 to_date=today_plus_3, 

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

2704 ) 

2705 ).host_request_id 

2706 

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

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

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

2710 

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

2712 with requests_session(surfer_token) as api: 

2713 api.SendHostRequestMessage( 

2714 requests_pb2.SendHostRequestMessageReq( 

2715 host_request_id=hr_id, 

2716 text="Follow-up message 1", 

2717 ) 

2718 ) 

2719 api.SendHostRequestMessage( 

2720 requests_pb2.SendHostRequestMessageReq( 

2721 host_request_id=hr_id, 

2722 text="Follow-up message 2", 

2723 ) 

2724 ) 

2725 

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

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

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

2729 

2730 # Now approve the request 

2731 moderator.approve_host_request(hr_id) 

2732 email_collector.pop_for_recipient(host.email) 

2733 

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

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

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

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

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

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

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

2741 

2742 

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

2744 """ 

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

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

2747 

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

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

2750 """ 

2751 host, host_token = generate_user(complete_profile=True) 

2752 surfer, surfer_token = generate_user(complete_profile=True) 

2753 

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

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

2756 

2757 # Create host request 

2758 with requests_session(surfer_token) as api: 

2759 hr_id = api.CreateHostRequest( 

2760 requests_pb2.CreateHostRequestReq( 

2761 host_user_id=host.id, 

2762 from_date=today_plus_2, 

2763 to_date=today_plus_3, 

2764 text=valid_request_text(), 

2765 ) 

2766 ).host_request_id 

2767 

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

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

2770 

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

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

2773 with requests_session(surfer_token) as api: 

2774 api.RespondHostRequest( 

2775 requests_pb2.RespondHostRequestReq( 

2776 host_request_id=hr_id, 

2777 status=messages_pb2.HOST_REQUEST_STATUS_CANCELLED, 

2778 text="Actually, never mind", 

2779 ) 

2780 ) 

2781 

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

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

2784 

2785 

2786def test_host_request_notifications_sent_after_approval( 

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

2788): 

2789 """ 

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

2791 """ 

2792 host, host_token = generate_user(complete_profile=True) 

2793 surfer, surfer_token = generate_user(complete_profile=True) 

2794 

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

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

2797 

2798 # Create and approve host request 

2799 with requests_session(surfer_token) as api: 

2800 hr_id = api.CreateHostRequest( 

2801 requests_pb2.CreateHostRequestReq( 

2802 host_user_id=host.id, 

2803 from_date=today_plus_2, 

2804 to_date=today_plus_3, 

2805 text=valid_request_text(), 

2806 ) 

2807 ).host_request_id 

2808 

2809 moderator.approve_host_request(hr_id) 

2810 

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

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

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

2814 

2815 # Host accepts the request - surfer should be notified 

2816 with requests_session(host_token) as api: 

2817 api.RespondHostRequest( 

2818 requests_pb2.RespondHostRequestReq( 

2819 host_request_id=hr_id, 

2820 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

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

2822 ) 

2823 ) 

2824 

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

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

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

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

2829 

2830 # Surfer confirms - host should be notified 

2831 with requests_session(surfer_token) as api: 

2832 api.RespondHostRequest( 

2833 requests_pb2.RespondHostRequestReq( 

2834 host_request_id=hr_id, 

2835 status=messages_pb2.HOST_REQUEST_STATUS_CONFIRMED, 

2836 text="See you then!", 

2837 ) 

2838 ) 

2839 

2840 # Host should now have received the confirmation notifications 

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

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

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

2844 

2845 

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

2847 """ 

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

2849 that haven't been approved yet. 

2850 """ 

2851 user1, token1 = generate_user(complete_profile=True) 

2852 user2, token2 = generate_user(complete_profile=True) 

2853 

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

2855 with conversations_session(token1) as api: 

2856 res = api.CreateGroupChat( 

2857 conversations_pb2.CreateGroupChatReq( 

2858 recipient_user_ids=[user2.id], 

2859 ) 

2860 ) 

2861 gc_id = res.group_chat_id 

2862 

2863 # Verify initial state 

2864 with session_scope() as session: 

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

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

2867 

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

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

2870 

2871 # Send messages BEFORE approval 

2872 with conversations_session(token1) as api: 

2873 api.SendMessage( 

2874 conversations_pb2.SendMessageReq( 

2875 group_chat_id=gc_id, 

2876 text="Hello before approval", 

2877 ) 

2878 ) 

2879 

2880 # Process the queued notification job 

2881 while process_job(): 

2882 pass 

2883 

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

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

2886 

2887 # Now approve the group chat 

2888 moderator.approve_group_chat(gc_id) 

2889 

2890 # Process the queued notification jobs from approval 

2891 while process_job(): 

2892 pass 

2893 

2894 # Verify moderation state after approval 

2895 with session_scope() as session: 

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

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

2898 

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

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

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

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

2903 

2904 # Send a message AFTER approval 

2905 with conversations_session(token1) as api: 

2906 api.SendMessage( 

2907 conversations_pb2.SendMessageReq( 

2908 group_chat_id=gc_id, 

2909 text="Hello after approval", 

2910 ) 

2911 ) 

2912 

2913 # Process the queued notification job 

2914 while process_job(): 

2915 pass 

2916 

2917 # User2 should have received another notification 

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

2919 

2920 

2921def test_event_moderation_state_content(db): 

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

2923 super_user, super_token = generate_user(is_superuser=True) 

2924 user, token = generate_user() 

2925 

2926 with session_scope() as session: 

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

2928 

2929 start_time = now() + timedelta(hours=2) 

2930 end_time = start_time + timedelta(hours=3) 

2931 

2932 with events_session(token) as api: 

2933 res = api.CreateEvent( 

2934 events_pb2.CreateEventReq( 

2935 title="My Event Title", 

2936 content="My event description.", 

2937 photo_key=None, 

2938 location=events_pb2.EventLocation( 

2939 address="Near Null Island", 

2940 lat=0.1, 

2941 lng=0.2, 

2942 ), 

2943 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time), 

2944 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time), 

2945 ) 

2946 ) 

2947 event_id = res.event_id 

2948 

2949 with session_scope() as session: 

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

2951 state_id = occurrence.moderation_state_id 

2952 

2953 with real_moderation_session(super_token) as api: 

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

2955 event_items = [ 

2956 item 

2957 for item in res.queue_items 

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

2959 ] 

2960 assert len(event_items) == 1 

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

2962 

2963 

2964# ============================================================================ 

2965# Tests for SetUserContentVisibility 

2966# ============================================================================ 

2967 

2968 

2969def _get_moderation_state(session, object_type, object_id): 

2970 return session.execute( 

2971 select(ModerationState) 

2972 .where(ModerationState.object_type == object_type) 

2973 .where(ModerationState.object_id == object_id) 

2974 ).scalar_one() 

2975 

2976 

2977def test_SetUserContentVisibility_host_request(db): 

2978 super_user, super_token = generate_user(is_superuser=True) 

2979 surfer, surfer_token = generate_user() 

2980 host, _ = generate_user() 

2981 

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

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

2984 with requests_session(surfer_token) as api: 

2985 hr_id = api.CreateHostRequest( 

2986 requests_pb2.CreateHostRequestReq( 

2987 host_user_id=host.id, 

2988 from_date=today_plus_2, 

2989 to_date=today_plus_3, 

2990 text=valid_request_text(), 

2991 ) 

2992 ).host_request_id 

2993 

2994 with real_moderation_session(super_token) as api: 

2995 res = api.SetUserContentVisibility( 

2996 moderation_pb2.SetUserContentVisibilityReq( 

2997 user_id=surfer.id, 

2998 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

2999 ) 

3000 ) 

3001 # Already shadowed by default — no-op 

3002 assert res.updated_count == 0 

3003 

3004 with real_moderation_session(super_token) as api: 

3005 res = api.SetUserContentVisibility( 

3006 moderation_pb2.SetUserContentVisibilityReq( 

3007 user_id=surfer.id, 

3008 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3009 reason="policy violation", 

3010 ) 

3011 ) 

3012 assert res.updated_count == 1 

3013 

3014 with session_scope() as session: 

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

3016 assert state.visibility == ModerationVisibility.hidden 

3017 

3018 log_entries = ( 

3019 session.execute( 

3020 select(ModerationLog) 

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

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

3023 ) 

3024 .scalars() 

3025 .all() 

3026 ) 

3027 # create log + bulk update log 

3028 assert len(log_entries) == 2 

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

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

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

3032 

3033 

3034def test_SetUserContentVisibility_group_chat(db): 

3035 super_user, super_token = generate_user(is_superuser=True) 

3036 creator, creator_token = generate_user() 

3037 other, _ = generate_user() 

3038 make_friends(creator, other) 

3039 

3040 with conversations_session(creator_token) as api: 

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

3042 

3043 with real_moderation_session(super_token) as api: 

3044 api.SetUserContentVisibility( 

3045 moderation_pb2.SetUserContentVisibilityReq( 

3046 user_id=creator.id, 

3047 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3048 ) 

3049 ) 

3050 

3051 with session_scope() as session: 

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

3053 assert state.visibility == ModerationVisibility.hidden 

3054 

3055 

3056def test_SetUserContentVisibility_event_occurrence(db): 

3057 super_user, super_token = generate_user(is_superuser=True) 

3058 creator, creator_token = generate_user() 

3059 

3060 with session_scope() as session: 

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

3062 

3063 start_time = now() + timedelta(hours=2) 

3064 end_time = start_time + timedelta(hours=3) 

3065 with events_session(creator_token) as api: 

3066 event_id = api.CreateEvent( 

3067 events_pb2.CreateEventReq( 

3068 title="Event", 

3069 content="Event description.", 

3070 photo_key=None, 

3071 location=events_pb2.EventLocation( 

3072 address="Near Null Island", 

3073 lat=0.1, 

3074 lng=0.2, 

3075 ), 

3076 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time), 

3077 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time), 

3078 ) 

3079 ).event_id 

3080 

3081 with real_moderation_session(super_token) as api: 

3082 res = api.SetUserContentVisibility( 

3083 moderation_pb2.SetUserContentVisibilityReq( 

3084 user_id=creator.id, 

3085 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3086 ) 

3087 ) 

3088 assert res.updated_count == 1 

3089 

3090 with session_scope() as session: 

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

3092 assert state.visibility == ModerationVisibility.hidden 

3093 

3094 

3095def test_SetUserContentVisibility_friend_request(db): 

3096 super_user, super_token = generate_user(is_superuser=True) 

3097 sender, sender_token = generate_user() 

3098 recipient, _ = generate_user() 

3099 

3100 with api_session(sender_token) as api: 

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

3102 

3103 with session_scope() as session: 

3104 fr_id = session.execute( 

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

3106 ).scalar_one() 

3107 

3108 with real_moderation_session(super_token) as api: 

3109 res = api.SetUserContentVisibility( 

3110 moderation_pb2.SetUserContentVisibilityReq( 

3111 user_id=sender.id, 

3112 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3113 ) 

3114 ) 

3115 assert res.updated_count == 1 

3116 

3117 with session_scope() as session: 

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

3119 assert state.visibility == ModerationVisibility.hidden 

3120 

3121 

3122def test_SetUserContentVisibility_round_trip(db): 

3123 super_user, super_token = generate_user(is_superuser=True) 

3124 surfer, surfer_token = generate_user() 

3125 host, _ = generate_user() 

3126 

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

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

3129 with requests_session(surfer_token) as api: 

3130 hr_id = api.CreateHostRequest( 

3131 requests_pb2.CreateHostRequestReq( 

3132 host_user_id=host.id, 

3133 from_date=today_plus_2, 

3134 to_date=today_plus_3, 

3135 text=valid_request_text(), 

3136 ) 

3137 ).host_request_id 

3138 

3139 with real_moderation_session(super_token) as api: 

3140 api.SetUserContentVisibility( 

3141 moderation_pb2.SetUserContentVisibilityReq( 

3142 user_id=surfer.id, 

3143 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3144 reason="first", 

3145 ) 

3146 ) 

3147 api.SetUserContentVisibility( 

3148 moderation_pb2.SetUserContentVisibilityReq( 

3149 user_id=surfer.id, 

3150 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

3151 reason="second", 

3152 ) 

3153 ) 

3154 

3155 with session_scope() as session: 

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

3157 assert state.visibility == ModerationVisibility.visible 

3158 

3159 bulk_log_entries = ( 

3160 session.execute( 

3161 select(ModerationLog) 

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

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

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

3165 ) 

3166 .scalars() 

3167 .all() 

3168 ) 

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

3170 ModerationVisibility.hidden, 

3171 ModerationVisibility.visible, 

3172 ] 

3173 

3174 

3175def test_SetUserContentVisibility_resolves_queue_items(db): 

3176 super_user, super_token = generate_user(is_superuser=True) 

3177 surfer, surfer_token = generate_user() 

3178 host, _ = generate_user() 

3179 

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

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

3182 with requests_session(surfer_token) as api: 

3183 hr_id = api.CreateHostRequest( 

3184 requests_pb2.CreateHostRequestReq( 

3185 host_user_id=host.id, 

3186 from_date=today_plus_2, 

3187 to_date=today_plus_3, 

3188 text=valid_request_text(), 

3189 ) 

3190 ).host_request_id 

3191 

3192 with session_scope() as session: 

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

3194 queue_item = session.execute( 

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

3196 ).scalar_one() 

3197 assert queue_item.resolved_by_log_id is None 

3198 

3199 with real_moderation_session(super_token) as api: 

3200 api.SetUserContentVisibility( 

3201 moderation_pb2.SetUserContentVisibilityReq( 

3202 user_id=surfer.id, 

3203 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3204 ) 

3205 ) 

3206 

3207 with session_scope() as session: 

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

3209 queue_item = session.execute( 

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

3211 ).scalar_one() 

3212 assert queue_item.resolved_by_log_id is not None 

3213 

3214 

3215def test_SetUserContentVisibility_noop_when_matches(db): 

3216 super_user, super_token = generate_user(is_superuser=True) 

3217 surfer, surfer_token = generate_user() 

3218 host, _ = generate_user() 

3219 

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

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

3222 with requests_session(surfer_token) as api: 

3223 api.CreateHostRequest( 

3224 requests_pb2.CreateHostRequestReq( 

3225 host_user_id=host.id, 

3226 from_date=today_plus_2, 

3227 to_date=today_plus_3, 

3228 text=valid_request_text(), 

3229 ) 

3230 ) 

3231 

3232 with session_scope() as session: 

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

3234 

3235 with real_moderation_session(super_token) as api: 

3236 res = api.SetUserContentVisibility( 

3237 moderation_pb2.SetUserContentVisibilityReq( 

3238 user_id=surfer.id, 

3239 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

3240 ) 

3241 ) 

3242 assert res.updated_count == 0 

3243 

3244 with session_scope() as session: 

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

3246 assert log_count_after == log_count_before 

3247 

3248 

3249def test_SetUserContentVisibility_unspecified_rejected(db): 

3250 super_user, super_token = generate_user(is_superuser=True) 

3251 target, _ = generate_user() 

3252 

3253 with real_moderation_session(super_token) as api: 

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

3255 api.SetUserContentVisibility( 

3256 moderation_pb2.SetUserContentVisibilityReq( 

3257 user_id=target.id, 

3258 visibility=moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED, 

3259 ) 

3260 ) 

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

3262 

3263 

3264def test_SetUserContentVisibility_non_admin_rejected(db): 

3265 normal_user, normal_token = generate_user() 

3266 target, _ = generate_user() 

3267 

3268 with real_moderation_session(normal_token) as api: 

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

3270 api.SetUserContentVisibility( 

3271 moderation_pb2.SetUserContentVisibilityReq( 

3272 user_id=target.id, 

3273 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3274 ) 

3275 ) 

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

3277 

3278 

3279def test_SetUserContentVisibility_writes_admin_action(db): 

3280 super_user, super_token = generate_user(is_superuser=True) 

3281 surfer, surfer_token = generate_user() 

3282 host, _ = generate_user() 

3283 

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

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

3286 with requests_session(surfer_token) as api: 

3287 api.CreateHostRequest( 

3288 requests_pb2.CreateHostRequestReq( 

3289 host_user_id=host.id, 

3290 from_date=today_plus_2, 

3291 to_date=today_plus_3, 

3292 text=valid_request_text(), 

3293 ) 

3294 ) 

3295 

3296 with real_moderation_session(super_token) as api: 

3297 api.SetUserContentVisibility( 

3298 moderation_pb2.SetUserContentVisibilityReq( 

3299 user_id=surfer.id, 

3300 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3301 reason="bulk hide", 

3302 ) 

3303 ) 

3304 

3305 with session_scope() as session: 

3306 actions = ( 

3307 session.execute( 

3308 select(AdminAction) 

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

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

3311 ) 

3312 .scalars() 

3313 .all() 

3314 ) 

3315 assert len(actions) == 1 

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

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

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

3319 

3320 

3321def test_SetUserContentVisibility_only_touches_target(db): 

3322 super_user, super_token = generate_user(is_superuser=True) 

3323 target, target_token = generate_user() 

3324 other, other_token = generate_user() 

3325 host, _ = generate_user() 

3326 

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

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

3329 

3330 with requests_session(target_token) as api: 

3331 target_hr_id = api.CreateHostRequest( 

3332 requests_pb2.CreateHostRequestReq( 

3333 host_user_id=host.id, 

3334 from_date=today_plus_2, 

3335 to_date=today_plus_3, 

3336 text=valid_request_text(), 

3337 ) 

3338 ).host_request_id 

3339 

3340 with requests_session(other_token) as api: 

3341 other_hr_id = api.CreateHostRequest( 

3342 requests_pb2.CreateHostRequestReq( 

3343 host_user_id=host.id, 

3344 from_date=today_plus_2, 

3345 to_date=today_plus_3, 

3346 text=valid_request_text(), 

3347 ) 

3348 ).host_request_id 

3349 

3350 with real_moderation_session(super_token) as api: 

3351 res = api.SetUserContentVisibility( 

3352 moderation_pb2.SetUserContentVisibilityReq( 

3353 user_id=target.id, 

3354 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3355 ) 

3356 ) 

3357 assert res.updated_count == 1 

3358 

3359 with session_scope() as session: 

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

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

3362 assert target_state.visibility == ModerationVisibility.hidden 

3363 assert other_state.visibility == ModerationVisibility.shadowed 

3364 

3365 

3366def test_SetUserContentVisibility_user_not_found(db): 

3367 super_user, super_token = generate_user(is_superuser=True) 

3368 

3369 with real_moderation_session(super_token) as api: 

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

3371 api.SetUserContentVisibility( 

3372 moderation_pb2.SetUserContentVisibilityReq( 

3373 user_id=999999, 

3374 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3375 ) 

3376 ) 

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

3378 

3379 

3380def test_SetUserContentVisibility_from_visibility_filter(db): 

3381 super_user, super_token = generate_user(is_superuser=True) 

3382 surfer, surfer_token = generate_user() 

3383 host, _ = generate_user() 

3384 

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

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

3387 with requests_session(surfer_token) as api: 

3388 hr_id = api.CreateHostRequest( 

3389 requests_pb2.CreateHostRequestReq( 

3390 host_user_id=host.id, 

3391 from_date=today_plus_2, 

3392 to_date=today_plus_3, 

3393 text=valid_request_text(), 

3394 ) 

3395 ).host_request_id 

3396 

3397 with real_moderation_session(super_token) as api: 

3398 api.SetUserContentVisibility( 

3399 moderation_pb2.SetUserContentVisibilityReq( 

3400 user_id=surfer.id, 

3401 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

3402 ) 

3403 ) 

3404 

3405 with real_moderation_session(super_token) as api: 

3406 res = api.SetUserContentVisibility( 

3407 moderation_pb2.SetUserContentVisibilityReq( 

3408 user_id=surfer.id, 

3409 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3410 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_SHADOWED], 

3411 ) 

3412 ) 

3413 assert res.updated_count == 0 

3414 

3415 with session_scope() as session: 

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

3417 assert state.visibility == ModerationVisibility.visible 

3418 

3419 with real_moderation_session(super_token) as api: 

3420 res = api.SetUserContentVisibility( 

3421 moderation_pb2.SetUserContentVisibilityReq( 

3422 user_id=surfer.id, 

3423 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

3424 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_VISIBLE], 

3425 ) 

3426 ) 

3427 assert res.updated_count == 1 

3428 

3429 with session_scope() as session: 

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

3431 assert state.visibility == ModerationVisibility.shadowed 

3432 

3433 

3434def test_SetUserContentVisibility_from_visibility_multi(db): 

3435 super_user, super_token = generate_user(is_superuser=True) 

3436 surfer, surfer_token = generate_user() 

3437 host1, _ = generate_user() 

3438 host2, _ = generate_user() 

3439 

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

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

3442 with requests_session(surfer_token) as api: 

3443 hr1_id = api.CreateHostRequest( 

3444 requests_pb2.CreateHostRequestReq( 

3445 host_user_id=host1.id, 

3446 from_date=today_plus_2, 

3447 to_date=today_plus_3, 

3448 text=valid_request_text(), 

3449 ) 

3450 ).host_request_id 

3451 hr2_id = api.CreateHostRequest( 

3452 requests_pb2.CreateHostRequestReq( 

3453 host_user_id=host2.id, 

3454 from_date=today_plus_2, 

3455 to_date=today_plus_3, 

3456 text=valid_request_text(), 

3457 ) 

3458 ).host_request_id 

3459 

3460 with session_scope() as session: 

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

3462 

3463 with real_moderation_session(super_token) as api: 

3464 api.ModerateContent( 

3465 moderation_pb2.ModerateContentReq( 

3466 moderation_state_id=state1_id, 

3467 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

3468 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

3469 ) 

3470 ) 

3471 

3472 with real_moderation_session(super_token) as api: 

3473 res = api.SetUserContentVisibility( 

3474 moderation_pb2.SetUserContentVisibilityReq( 

3475 user_id=surfer.id, 

3476 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3477 from_visibility=[ 

3478 moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

3479 moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

3480 ], 

3481 ) 

3482 ) 

3483 assert res.updated_count == 2 

3484 

3485 with session_scope() as session: 

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

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

3488 assert state1.visibility == ModerationVisibility.hidden 

3489 assert state2.visibility == ModerationVisibility.hidden 

3490 

3491 

3492def test_SetUserContentVisibility_from_visibility_empty_is_any(db): 

3493 super_user, super_token = generate_user(is_superuser=True) 

3494 surfer, surfer_token = generate_user() 

3495 host, _ = generate_user() 

3496 

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

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

3499 with requests_session(surfer_token) as api: 

3500 hr_id = api.CreateHostRequest( 

3501 requests_pb2.CreateHostRequestReq( 

3502 host_user_id=host.id, 

3503 from_date=today_plus_2, 

3504 to_date=today_plus_3, 

3505 text=valid_request_text(), 

3506 ) 

3507 ).host_request_id 

3508 

3509 with real_moderation_session(super_token) as api: 

3510 res = api.SetUserContentVisibility( 

3511 moderation_pb2.SetUserContentVisibilityReq( 

3512 user_id=surfer.id, 

3513 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3514 from_visibility=[], 

3515 ) 

3516 ) 

3517 assert res.updated_count == 1 

3518 

3519 with session_scope() as session: 

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

3521 assert state.visibility == ModerationVisibility.hidden 

3522 

3523 

3524def test_SetUserContentVisibility_from_visibility_unspecified_rejected(db): 

3525 super_user, super_token = generate_user(is_superuser=True) 

3526 target, _ = generate_user() 

3527 

3528 with real_moderation_session(super_token) as api: 

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

3530 api.SetUserContentVisibility( 

3531 moderation_pb2.SetUserContentVisibilityReq( 

3532 user_id=target.id, 

3533 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3534 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED], 

3535 ) 

3536 ) 

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

3538 

3539 

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

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

3542 return [ 

3543 s.moderation_state_id 

3544 for s in res.moderation_states 

3545 if s.object_type != moderation_pb2.MODERATION_OBJECT_TYPE_USER 

3546 ] 

3547 

3548 

3549def test_ListModerationStates_with_no_content(db): 

3550 super_user, super_token = generate_user(is_superuser=True) 

3551 

3552 with real_moderation_session(super_token) as api: 

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

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

3555 assert _content_state_ids(res) == [] 

3556 assert res.next_page_token == "" 

3557 

3558 

3559def test_ListModerationStates_returns_states_chronologically(db): 

3560 super_user, super_token = generate_user(is_superuser=True) 

3561 surfer, surfer_token = generate_user() 

3562 host, _ = generate_user() 

3563 

3564 state1_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3565 state2_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3566 state3_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3567 

3568 with real_moderation_session(super_token) as api: 

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

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

3571 

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

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

3574 

3575 

3576def test_ListModerationStates_filter_by_author(db): 

3577 super_user, super_token = generate_user(is_superuser=True) 

3578 surfer1, surfer1_token = generate_user() 

3579 surfer2, surfer2_token = generate_user() 

3580 host, _ = generate_user() 

3581 

3582 state1_id = create_test_host_request_with_moderation(surfer1_token, host.id) 

3583 state2_id = create_test_host_request_with_moderation(surfer2_token, host.id) 

3584 state3_id = create_test_host_request_with_moderation(surfer1_token, host.id) 

3585 

3586 with real_moderation_session(super_token) as api: 

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

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

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

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

3591 

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

3593 assert _content_state_ids(res) == [state2_id] 

3594 

3595 

3596def test_ListModerationStates_pagination(db): 

3597 super_user, super_token = generate_user(is_superuser=True) 

3598 surfer, surfer_token = generate_user() 

3599 host, _ = generate_user() 

3600 

3601 for _ in range(3): 

3602 create_test_host_request_with_moderation(surfer_token, host.id) 

3603 

3604 with real_moderation_session(super_token) as api: 

3605 # 3 users plus 3 host requests 

3606 all_ids = [ 

3607 s.moderation_state_id 

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

3609 ] 

3610 assert len(all_ids) == 6 

3611 

3612 paged_ids = [] 

3613 page_token = "" 

3614 while True: 

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

3616 assert len(res.moderation_states) == 2 

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

3618 page_token = res.next_page_token 

3619 if not page_token: 

3620 break 

3621 

3622 assert paged_ids == all_ids 

3623 

3624 

3625def test_ListModerationStates_pagination_ignores_created(db): 

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

3627 super_user, super_token = generate_user(is_superuser=True) 

3628 surfer, surfer_token = generate_user() 

3629 host, _ = generate_user() 

3630 

3631 first_state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3632 create_test_host_request_with_moderation(surfer_token, host.id) 

3633 create_test_host_request_with_moderation(surfer_token, host.id) 

3634 

3635 with session_scope() as session: 

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

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

3638 

3639 paged_ids = [] 

3640 page_token = "" 

3641 with real_moderation_session(super_token) as api: 

3642 while True: 

3643 res = api.ListModerationStates( 

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

3645 ) 

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

3647 page_token = res.next_page_token 

3648 if not page_token: 

3649 break 

3650 

3651 with session_scope() as session: 

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

3653 

3654 assert paged_ids == expected 

3655 

3656 

3657# ============================================================================ 

3658# Tests for user account moderation states 

3659# ============================================================================ 

3660 

3661 

3662def test_user_moderation_state_created_with_the_account(db): 

3663 user, _ = generate_user() 

3664 

3665 with session_scope() as session: 

3666 state = session.execute( 

3667 select(ModerationState) 

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

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

3670 ).scalar_one() 

3671 

3672 assert state.visibility is None 

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

3674 

3675 log_entries = ( 

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

3677 ) 

3678 assert len(log_entries) == 1 

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

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

3681 

3682 queue_items = ( 

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

3684 .scalars() 

3685 .all() 

3686 ) 

3687 assert queue_items == [] 

3688 

3689 

3690def test_GetModerationState_for_user(db): 

3691 super_user, super_token = generate_user(is_superuser=True) 

3692 user, _ = generate_user() 

3693 

3694 with real_moderation_session(super_token) as api: 

3695 state = api.GetModerationState( 

3696 moderation_pb2.GetModerationStateReq( 

3697 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3698 object_id=user.id, 

3699 ) 

3700 ).moderation_state 

3701 assert state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_USER 

3702 assert state.object_id == user.id 

3703 assert state.author_user_id == user.id 

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

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

3706 assert state.visibility == moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED 

3707 

3708 

3709def test_GetModerationState_for_nonexistent_user(db): 

3710 super_user, super_token = generate_user(is_superuser=True) 

3711 

3712 with real_moderation_session(super_token) as api: 

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

3714 api.GetModerationState( 

3715 moderation_pb2.GetModerationStateReq( 

3716 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3717 object_id=999999, 

3718 ) 

3719 ) 

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

3721 

3722 

3723def test_flag_user_shows_up_in_queue_and_author_filters(db): 

3724 super_user, super_token = generate_user(is_superuser=True) 

3725 surfer, surfer_token = generate_user() 

3726 host, _ = generate_user() 

3727 other, _ = generate_user() 

3728 

3729 content_state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3730 

3731 with real_moderation_session(super_token) as api: 

3732 user_state_id = api.GetModerationState( 

3733 moderation_pb2.GetModerationStateReq( 

3734 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3735 object_id=surfer.id, 

3736 ) 

3737 ).moderation_state.moderation_state_id 

3738 

3739 api.ModerateContent( 

3740 moderation_pb2.ModerateContentReq( 

3741 moderation_state_id=user_state_id, 

3742 action=moderation_pb2.MODERATION_ACTION_FLAG, 

3743 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

3744 reason="Reported for spamming hosts", 

3745 ) 

3746 ) 

3747 

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

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

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

3751 assert user_item.trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG 

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

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

3754 

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

3756 assert len(res.queue_items) == 0 

3757 

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

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

3760 

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

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

3763 

3764 

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

3766def test_ModerateContent_cannot_set_visibility_on_user_state(db, action): 

3767 super_user, super_token = generate_user(is_superuser=True) 

3768 user, _ = generate_user() 

3769 

3770 with real_moderation_session(super_token) as api: 

3771 user_state_id = api.GetModerationState( 

3772 moderation_pb2.GetModerationStateReq( 

3773 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3774 object_id=user.id, 

3775 ) 

3776 ).moderation_state.moderation_state_id 

3777 

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

3779 api.ModerateContent( 

3780 moderation_pb2.ModerateContentReq( 

3781 moderation_state_id=user_state_id, 

3782 action=action, 

3783 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3784 ) 

3785 ) 

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

3787 

3788 with session_scope() as session: 

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

3790 assert state.visibility is None 

3791 

3792 

3793def test_SetUserContentVisibility_leaves_user_state_alone(db): 

3794 super_user, super_token = generate_user(is_superuser=True) 

3795 surfer, surfer_token = generate_user() 

3796 host, _ = generate_user() 

3797 

3798 content_state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3799 

3800 with real_moderation_session(super_token) as api: 

3801 user_state_id = api.GetModerationState( 

3802 moderation_pb2.GetModerationStateReq( 

3803 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3804 object_id=surfer.id, 

3805 ) 

3806 ).moderation_state.moderation_state_id 

3807 

3808 res = api.SetUserContentVisibility( 

3809 moderation_pb2.SetUserContentVisibilityReq( 

3810 user_id=surfer.id, 

3811 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3812 ) 

3813 ) 

3814 assert res.updated_count == 1 

3815 

3816 with session_scope() as session: 

3817 content_state = session.execute( 

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

3819 ).scalar_one() 

3820 assert content_state.visibility == ModerationVisibility.hidden 

3821 

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

3823 assert user_state.visibility is None 

3824 

3825 

3826# ============================================================================ 

3827# Tests for the moderation model metadata 

3828# ============================================================================ 

3829 

3830 

3831def test_every_object_type_has_a_model(): 

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

3833 assert set(get_moderated_models()) == set(ModerationObjectType) 

3834 

3835 

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

3837def test_visibility_check_constraint_matches_the_models(db, object_type): 

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

3839 has_own_visibility_mechanism = get_moderated_models()[object_type].has_own_visibility_mechanism 

3840 

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

3842 with session_scope() as session: 

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

3844 

3845 if has_own_visibility_mechanism: 

3846 add_state(1, None) 

3847 with pytest.raises(IntegrityError): 

3848 add_state(2, ModerationVisibility.visible) 

3849 else: 

3850 add_state(1, ModerationVisibility.visible) 

3851 with pytest.raises(IntegrityError): 

3852 add_state(2, None)