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

1641 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-10 12:25 +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 

60@pytest.fixture(autouse=True) 

61def _(testconfig): 

62 pass 

63 

64 

65def create_test_host_request_with_moderation(surfer_token, host_user_id): 

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

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

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

69 

70 with requests_session(surfer_token) as api: 

71 hr_id = api.CreateHostRequest( 

72 requests_pb2.CreateHostRequestReq( 

73 host_user_id=host_user_id, 

74 from_date=today_plus_2, 

75 to_date=today_plus_3, 

76 text=valid_request_text(), 

77 ) 

78 ).host_request_id 

79 

80 backdate_conversations() 

81 

82 with session_scope() as session: 

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

84 return hr.moderation_state_id 

85 

86 

87# ============================================================================ 

88# Tests for moderation helper functions 

89# ============================================================================ 

90 

91 

92def test_create_moderation(db): 

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

94 user, _ = generate_user() 

95 

96 with session_scope() as session: 

97 # Create a moderation state 

98 moderation_state = create_moderation( 

99 session=session, 

100 object_type=ModerationObjectType.host_request, 

101 object_id=123, 

102 creator_user_id=user.id, 

103 ) 

104 

105 assert moderation_state.object_type == ModerationObjectType.host_request 

106 assert moderation_state.object_id == 123 

107 assert moderation_state.visibility == ModerationVisibility.shadowed 

108 

109 # Check that log entry was created 

110 log_entries = ( 

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

112 .scalars() 

113 .all() 

114 ) 

115 

116 assert len(log_entries) == 1 

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

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

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

120 

121 

122def test_add_to_moderation_queue(db): 

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

124 super_user, super_token = generate_user(is_superuser=True) 

125 user1, token1 = generate_user() 

126 user2, _ = generate_user() 

127 

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

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

130 

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

132 with requests_session(token1) as api: 

133 host_request_id = api.CreateHostRequest( 

134 requests_pb2.CreateHostRequestReq( 

135 host_user_id=user2.id, 

136 from_date=today_plus_2, 

137 to_date=today_plus_3, 

138 text=valid_request_text(), 

139 ) 

140 ).host_request_id 

141 

142 # Get the moderation state ID 

143 state_id = None 

144 with session_scope() as session: 

145 host_request = session.execute( 

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

147 ).scalar_one() 

148 state_id = host_request.moderation_state_id 

149 

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

151 with real_moderation_session(super_token) as api: 

152 api.ModerateContent( 

153 moderation_pb2.ModerateContentReq( 

154 moderation_state_id=state_id, 

155 action=moderation_pb2.MODERATION_ACTION_FLAG, 

156 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

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

158 ) 

159 ) 

160 

161 with session_scope() as session: 

162 flag = session.execute( 

163 select(ModerationQueueItem) 

164 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

166 ).scalar_one() 

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

168 assert flag.resolved_by_log_id is None 

169 

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

171 flag_log = session.execute( 

172 select(ModerationLog) 

173 .where(ModerationLog.moderation_state_id == state_id) 

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

175 ).scalar_one() 

176 assert flag_log.queue_item_id == flag.id 

177 

178 

179def test_flag_with_data(db): 

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

181 super_user, super_token = generate_user(is_superuser=True) 

182 user1, token1 = generate_user() 

183 user2, _ = generate_user() 

184 

185 state_id = create_test_host_request_with_moderation(token1, user2.id) 

186 

187 with real_moderation_session(super_token) as api: 

188 api.ModerateContent( 

189 moderation_pb2.ModerateContentReq( 

190 moderation_state_id=state_id, 

191 action=moderation_pb2.MODERATION_ACTION_FLAG, 

192 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG, 

193 reason="Automod", 

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

195 ) 

196 ) 

197 

198 res = api.GetModerationQueue( 

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

200 ) 

201 assert len(res.queue_items) == 1 

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

203 "classifier": "spam", 

204 "score": 0.97, 

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

206 } 

207 

208 with session_scope() as session: 

209 flag = session.execute( 

210 select(ModerationQueueItem) 

211 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

213 ).scalar_one() 

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

215 

216 

217def test_flag_without_data(db): 

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

219 super_user, super_token = generate_user(is_superuser=True) 

220 user1, token1 = generate_user() 

221 user2, _ = generate_user() 

222 

223 state_id = create_test_host_request_with_moderation(token1, user2.id) 

224 

225 with real_moderation_session(super_token) as api: 

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

227 assert len(res.queue_items) == 1 

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

229 

230 with session_scope() as session: 

231 item = session.execute( 

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

233 ).scalar_one() 

234 assert item.data is None 

235 

236 

237def test_flag_with_invalid_data(db): 

238 """Flag data must be valid JSON""" 

239 super_user, super_token = generate_user(is_superuser=True) 

240 user1, token1 = generate_user() 

241 user2, _ = generate_user() 

242 

243 state_id = create_test_host_request_with_moderation(token1, user2.id) 

244 

245 with real_moderation_session(super_token) as api: 

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

247 api.ModerateContent( 

248 moderation_pb2.ModerateContentReq( 

249 moderation_state_id=state_id, 

250 action=moderation_pb2.MODERATION_ACTION_FLAG, 

251 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG, 

252 reason="Automod", 

253 data="not json", 

254 ) 

255 ) 

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

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

258 

259 

260def test_data_rejected_on_non_flag_action(db): 

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

262 super_user, super_token = generate_user(is_superuser=True) 

263 user1, token1 = generate_user() 

264 user2, _ = generate_user() 

265 

266 state_id = create_test_host_request_with_moderation(token1, user2.id) 

267 

268 with real_moderation_session(super_token) as api: 

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

270 api.ModerateContent( 

271 moderation_pb2.ModerateContentReq( 

272 moderation_state_id=state_id, 

273 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

274 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

275 reason="Looks fine", 

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

277 ) 

278 ) 

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

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

281 

282 

283def test_create_moderation_with_data(db): 

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

285 user, _ = generate_user() 

286 

287 with session_scope() as session: 

288 moderation_state = create_moderation( 

289 session=session, 

290 object_type=ModerationObjectType.host_request, 

291 object_id=123, 

292 creator_user_id=user.id, 

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

294 ) 

295 

296 item = session.execute( 

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

298 ).scalar_one() 

299 assert item.trigger == ModerationTrigger.initial_review 

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

301 

302 

303def test_moderate_content(db): 

304 """Test moderating content via API""" 

305 super_user, super_token = generate_user(is_superuser=True) 

306 user, token = generate_user() 

307 host, _ = generate_user() 

308 

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

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

311 

312 # Create a real host request 

313 state_id = None 

314 with requests_session(token) as api: 

315 hr_id = api.CreateHostRequest( 

316 requests_pb2.CreateHostRequestReq( 

317 host_user_id=host.id, 

318 from_date=today_plus_2, 

319 to_date=today_plus_3, 

320 text=valid_request_text(), 

321 ) 

322 ).host_request_id 

323 

324 with session_scope() as session: 

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

326 state_id = hr.moderation_state_id 

327 

328 # Moderate the content via API 

329 with real_moderation_session(super_token) as api: 

330 res = api.ModerateContent( 

331 moderation_pb2.ModerateContentReq( 

332 moderation_state_id=state_id, 

333 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

334 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

335 reason="Content looks good", 

336 ) 

337 ) 

338 

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

340 

341 # Check that state was updated in database 

342 with session_scope() as session: 

343 updated_state = session.get_one(ModerationState, state_id) 

344 assert updated_state.visibility == ModerationVisibility.visible 

345 

346 # Check that log entry was created 

347 log_entries = ( 

348 session.execute( 

349 select(ModerationLog) 

350 .where(ModerationLog.moderation_state_id == state_id) 

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

352 ) 

353 .scalars() 

354 .all() 

355 ) 

356 

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

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

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

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

361 

362 

363def test_resolve_queue_item(db): 

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

365 user1, token1 = generate_user() 

366 user2, _ = generate_user() 

367 moderator, moderator_token = generate_user(is_superuser=True) 

368 

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

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

371 

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

373 with requests_session(token1) as api: 

374 host_request_id = api.CreateHostRequest( 

375 requests_pb2.CreateHostRequestReq( 

376 host_user_id=user2.id, 

377 from_date=today_plus_2, 

378 to_date=today_plus_3, 

379 text=valid_request_text(), 

380 ) 

381 ).host_request_id 

382 

383 state_id = None 

384 with session_scope() as session: 

385 # Get the host request and its moderation state 

386 host_request = session.execute( 

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

388 ).scalar_one() 

389 state_id = host_request.moderation_state_id 

390 

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

392 queue_item = session.execute( 

393 select(ModerationQueueItem) 

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

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

396 ).scalar_one() 

397 

398 assert queue_item.resolved_by_log_id is None 

399 

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

401 with real_moderation_session(moderator_token) as api: 

402 api.ModerateContent( 

403 moderation_pb2.ModerateContentReq( 

404 moderation_state_id=state_id, 

405 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

406 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

407 reason="Approved after review", 

408 clear_flags=True, 

409 ) 

410 ) 

411 

412 # Check that queue item was resolved 

413 with session_scope() as session: 

414 queue_item = session.execute( 

415 select(ModerationQueueItem) 

416 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

418 ).scalar_one() 

419 assert queue_item.resolved_by_log_id is not None 

420 

421 

422def test_approve_content_via_api(db): 

423 """Test approving content via ModerateContent API""" 

424 user1, token1 = generate_user() 

425 user2, _ = generate_user() 

426 moderator, moderator_token = generate_user(is_superuser=True) 

427 

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

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

430 

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

432 with requests_session(token1) as api: 

433 host_request_id = api.CreateHostRequest( 

434 requests_pb2.CreateHostRequestReq( 

435 host_user_id=user2.id, 

436 from_date=today_plus_2, 

437 to_date=today_plus_3, 

438 text=valid_request_text(), 

439 ) 

440 ).host_request_id 

441 

442 state_id = None 

443 with session_scope() as session: 

444 # Get the host request and its moderation state 

445 host_request = session.execute( 

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

447 ).scalar_one() 

448 state_id = host_request.moderation_state_id 

449 

450 # Approve via API 

451 with real_moderation_session(moderator_token) as api: 

452 api.ModerateContent( 

453 moderation_pb2.ModerateContentReq( 

454 moderation_state_id=state_id, 

455 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

456 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

457 reason="Quick approval", 

458 ) 

459 ) 

460 

461 # Check that state was updated to VISIBLE 

462 with session_scope() as session: 

463 updated_state = session.get_one(ModerationState, state_id) 

464 assert updated_state.visibility == ModerationVisibility.visible 

465 

466 # Check log entry 

467 log_entry = session.execute( 

468 select(ModerationLog) 

469 .where(ModerationLog.moderation_state_id == state_id) 

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

471 ).scalar_one() 

472 

473 assert log_entry.moderator_user_id == moderator.id 

474 assert log_entry.reason == "Quick approval" 

475 

476 

477# ============================================================================ 

478# Tests for host request moderation integration 

479# ============================================================================ 

480 

481 

482def test_create_host_request_creates_moderation_state(db): 

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

484 user1, token1 = generate_user() 

485 user2, token2 = generate_user() 

486 

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

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

489 

490 with requests_session(token1) as api: 

491 host_request_id = api.CreateHostRequest( 

492 requests_pb2.CreateHostRequestReq( 

493 host_user_id=user2.id, 

494 from_date=today_plus_2, 

495 to_date=today_plus_3, 

496 text=valid_request_text(), 

497 ) 

498 ).host_request_id 

499 

500 with session_scope() as session: 

501 # Check that host request has a moderation state 

502 host_request = session.execute( 

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

504 ).scalar_one() 

505 

506 # Check moderation state properties 

507 moderation_state = session.execute( 

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

509 ).scalar_one() 

510 

511 assert moderation_state.object_type == ModerationObjectType.host_request 

512 assert moderation_state.object_id == host_request_id 

513 assert moderation_state.visibility == ModerationVisibility.shadowed 

514 

515 # Check that it was added to moderation queue 

516 queue_items = ( 

517 session.execute( 

518 select(ModerationQueueItem) 

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

520 .where(ModerationQueueItem.resolved_by_log_id == None) 

521 ) 

522 .scalars() 

523 .all() 

524 ) 

525 

526 assert len(queue_items) == 1 

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

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

529 

530 

531def test_host_request_no_notification_before_approval(db, push_collector: PushCollector): 

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

533 user1, token1 = generate_user() 

534 user2, token2 = generate_user() 

535 

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

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

538 

539 with requests_session(token1) as api: 

540 host_request_id = api.CreateHostRequest( 

541 requests_pb2.CreateHostRequestReq( 

542 host_user_id=user2.id, 

543 from_date=today_plus_2, 

544 to_date=today_plus_3, 

545 text=valid_request_text(), 

546 ) 

547 ).host_request_id 

548 

549 # Process all jobs (including the notification job) 

550 process_jobs() 

551 

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

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

554 

555 

556def test_shadowed_notification_not_in_list_notifications(db): 

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

558 user1, token1 = generate_user() 

559 user2, token2 = generate_user() 

560 

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

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

563 

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

565 with requests_session(token1) as api: 

566 host_request_id = api.CreateHostRequest( 

567 requests_pb2.CreateHostRequestReq( 

568 host_user_id=user2.id, 

569 from_date=today_plus_2, 

570 to_date=today_plus_3, 

571 text=valid_request_text(), 

572 ) 

573 ).host_request_id 

574 

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

576 with notifications_session(token2) as api: 

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

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

579 assert len(res.notifications) == 0 

580 

581 

582def test_notification_visible_after_approval(db): 

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

584 user1, token1 = generate_user() 

585 user2, token2 = generate_user() 

586 mod, mod_token = generate_user(is_superuser=True) 

587 

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

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

590 

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

592 with requests_session(token1) as api: 

593 host_request_id = api.CreateHostRequest( 

594 requests_pb2.CreateHostRequestReq( 

595 host_user_id=user2.id, 

596 from_date=today_plus_2, 

597 to_date=today_plus_3, 

598 text=valid_request_text(), 

599 ) 

600 ).host_request_id 

601 

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

603 with notifications_session(token2) as api: 

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

605 assert len(res.notifications) == 0 

606 

607 # Get the moderation state ID and approve 

608 with session_scope() as session: 

609 host_request = session.execute( 

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

611 ).scalar_one() 

612 state_id = host_request.moderation_state_id 

613 

614 with real_moderation_session(mod_token) as api: 

615 api.ModerateContent( 

616 moderation_pb2.ModerateContentReq( 

617 moderation_state_id=state_id, 

618 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

619 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

620 reason="Looks good", 

621 ) 

622 ) 

623 

624 # Now host SHOULD see the notification 

625 with notifications_session(token2) as api: 

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

627 assert len(res.notifications) == 1 

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

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

630 

631 

632def test_shadowed_host_request_visible_to_author_only(db): 

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

634 user1, token1 = generate_user() 

635 user2, token2 = generate_user() 

636 

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

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

639 

640 with requests_session(token1) as api: 

641 host_request_id = api.CreateHostRequest( 

642 requests_pb2.CreateHostRequestReq( 

643 host_user_id=user2.id, 

644 from_date=today_plus_2, 

645 to_date=today_plus_3, 

646 text=valid_request_text(), 

647 ) 

648 ).host_request_id 

649 

650 # Surfer (author) can see it with GetHostRequest 

651 with requests_session(token1) as api: 

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

653 assert res.host_request_id == host_request_id 

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

655 

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

657 with requests_session(token2) as api: 

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

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

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

661 

662 

663def test_unlisted_host_request_not_in_lists(db): 

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

665 user1, token1 = generate_user() 

666 user2, token2 = generate_user() 

667 

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

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

670 

671 with requests_session(token1) as api: 

672 host_request_id = api.CreateHostRequest( 

673 requests_pb2.CreateHostRequestReq( 

674 host_user_id=user2.id, 

675 from_date=today_plus_2, 

676 to_date=today_plus_3, 

677 text=valid_request_text(), 

678 ) 

679 ).host_request_id 

680 

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

682 with requests_session(token1) as api: 

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

684 assert len(res.host_requests) == 1 

685 

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

687 with requests_session(token2) as api: 

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

689 assert len(res.host_requests) == 0 

690 

691 

692def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector): 

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

694 user1, token1 = generate_user() 

695 user2, token2 = generate_user() 

696 mod, mod_token = generate_user(is_superuser=True) 

697 

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

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

700 

701 with requests_session(token1) as api: 

702 host_request_id = api.CreateHostRequest( 

703 requests_pb2.CreateHostRequestReq( 

704 host_user_id=user2.id, 

705 from_date=today_plus_2, 

706 to_date=today_plus_3, 

707 text=valid_request_text(), 

708 ) 

709 ).host_request_id 

710 

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

712 process_jobs() 

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

714 

715 # Get the moderation state ID 

716 state_id = None 

717 with session_scope() as session: 

718 host_request = session.execute( 

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

720 ).scalar_one() 

721 state_id = host_request.moderation_state_id 

722 

723 # Approve the host request via API 

724 with real_moderation_session(mod_token) as api: 

725 api.ModerateContent( 

726 moderation_pb2.ModerateContentReq( 

727 moderation_state_id=state_id, 

728 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

729 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

730 reason="Looks good", 

731 ) 

732 ) 

733 

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

735 process_jobs() 

736 

737 # Now surfer SHOULD see it in their sent list 

738 with requests_session(token1) as api: 

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

740 assert len(res.host_requests) == 1 

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

742 

743 # Host SHOULD see it in their received list 

744 with requests_session(token2) as api: 

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

746 assert len(res.host_requests) == 1 

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

748 

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

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

751 

752 

753def test_hidden_host_request_invisible_to_all(db): 

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

755 user1, token1 = generate_user() 

756 user2, token2 = generate_user() 

757 user3, token3 = generate_user() # Third party 

758 moderator, moderator_token = generate_user(is_superuser=True) 

759 

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

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

762 

763 with requests_session(token1) as api: 

764 host_request_id = api.CreateHostRequest( 

765 requests_pb2.CreateHostRequestReq( 

766 host_user_id=user2.id, 

767 from_date=today_plus_2, 

768 to_date=today_plus_3, 

769 text=valid_request_text(), 

770 ) 

771 ).host_request_id 

772 

773 # Get the moderation state ID 

774 state_id = None 

775 with session_scope() as session: 

776 host_request = session.execute( 

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

778 ).scalar_one() 

779 state_id = host_request.moderation_state_id 

780 

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

782 with real_moderation_session(moderator_token) as api: 

783 api.ModerateContent( 

784 moderation_pb2.ModerateContentReq( 

785 moderation_state_id=state_id, 

786 action=moderation_pb2.MODERATION_ACTION_HIDE, 

787 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

788 reason="Spam content", 

789 ) 

790 ) 

791 

792 # Surfer can't see it with GetHostRequest 

793 with requests_session(token1) as api: 

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

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

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

797 

798 # Host can't see it with GetHostRequest 

799 with requests_session(token2) as api: 

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

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

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

803 

804 # Third party definitely can't see it 

805 with requests_session(token3) as api: 

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

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

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

809 

810 # Not in any lists 

811 with requests_session(token1) as api: 

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

813 assert len(res.host_requests) == 0 

814 

815 with requests_session(token2) as api: 

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

817 assert len(res.host_requests) == 0 

818 

819 

820def test_multiple_host_requests_listing_visibility(db): 

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

822 user1, token1 = generate_user() 

823 user2, token2 = generate_user() 

824 moderator, moderator_token = generate_user(is_superuser=True) 

825 

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

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

828 

829 # Create 3 host requests 

830 host_request_ids = [] 

831 state_ids = [] 

832 with requests_session(token1) as api: 

833 for i in range(3): 

834 hr_id = api.CreateHostRequest( 

835 requests_pb2.CreateHostRequestReq( 

836 host_user_id=user2.id, 

837 from_date=today_plus_2, 

838 to_date=today_plus_3, 

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

840 ) 

841 ).host_request_id 

842 host_request_ids.append(hr_id) 

843 backdate_conversations() 

844 

845 # Get state IDs 

846 with session_scope() as session: 

847 for hr_id in host_request_ids: 

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

849 state_ids.append(host_request.moderation_state_id) 

850 

851 # Approve the first one via API 

852 with real_moderation_session(moderator_token) as api: 

853 api.ModerateContent( 

854 moderation_pb2.ModerateContentReq( 

855 moderation_state_id=state_ids[0], 

856 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

857 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

858 reason="Approved", 

859 ) 

860 ) 

861 

862 # Hide the third one via API 

863 with real_moderation_session(moderator_token) as api: 

864 api.ModerateContent( 

865 moderation_pb2.ModerateContentReq( 

866 moderation_state_id=state_ids[2], 

867 action=moderation_pb2.MODERATION_ACTION_HIDE, 

868 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

869 reason="Spam", 

870 ) 

871 ) 

872 

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

874 with requests_session(token1) as api: 

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

876 assert len(res.host_requests) == 2 

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

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

879 

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

881 with requests_session(token2) as api: 

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

883 assert len(res.host_requests) == 1 

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

885 

886 

887def test_moderation_log_tracking(db): 

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

889 user, user_token = generate_user() 

890 host, _ = generate_user() 

891 moderator1, moderator1_token = generate_user(is_superuser=True) 

892 moderator2, moderator2_token = generate_user(is_superuser=True) 

893 

894 # Create a real host request 

895 state_id = create_test_host_request_with_moderation(user_token, host.id) 

896 

897 # Perform several moderation actions via API 

898 with real_moderation_session(moderator1_token) as api: 

899 api.ModerateContent( 

900 moderation_pb2.ModerateContentReq( 

901 moderation_state_id=state_id, 

902 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

903 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

904 reason="Looks good initially", 

905 ) 

906 ) 

907 

908 with real_moderation_session(moderator2_token) as api: 

909 api.ModerateContent( 

910 moderation_pb2.ModerateContentReq( 

911 moderation_state_id=state_id, 

912 action=moderation_pb2.MODERATION_ACTION_FLAG, 

913 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

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

915 ) 

916 ) 

917 # Shadow it back 

918 api.ModerateContent( 

919 moderation_pb2.ModerateContentReq( 

920 moderation_state_id=state_id, 

921 action=moderation_pb2.MODERATION_ACTION_HIDE, 

922 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

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

924 ) 

925 ) 

926 

927 with real_moderation_session(moderator1_token) as api: 

928 api.ModerateContent( 

929 moderation_pb2.ModerateContentReq( 

930 moderation_state_id=state_id, 

931 action=moderation_pb2.MODERATION_ACTION_HIDE, 

932 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

933 reason="Actually it's spam", 

934 ) 

935 ) 

936 

937 # Check all log entries 

938 with session_scope() as session: 

939 log_entries = ( 

940 session.execute( 

941 select(ModerationLog) 

942 .where(ModerationLog.moderation_state_id == state_id) 

943 .order_by(ModerationLog.time.asc()) 

944 ) 

945 .scalars() 

946 .all() 

947 ) 

948 

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

950 assert len(log_entries) >= 3 

951 

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

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

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

955 

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

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

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

959 

960 # The last action should be hiding 

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

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

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

964 

965 

966def test_moderation_queue_workflow(db): 

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

968 user1, token1 = generate_user() 

969 user2, _ = generate_user() 

970 moderator, moderator_token = generate_user(is_superuser=True) 

971 

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

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

974 

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

976 with requests_session(token1) as api: 

977 host_request_id = api.CreateHostRequest( 

978 requests_pb2.CreateHostRequestReq( 

979 host_user_id=user2.id, 

980 from_date=today_plus_2, 

981 to_date=today_plus_3, 

982 text=valid_request_text(), 

983 ) 

984 ).host_request_id 

985 

986 state_id = None 

987 queue_item_id = None 

988 with session_scope() as session: 

989 # Get the host request and its moderation state 

990 host_request = session.execute( 

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

992 ).scalar_one() 

993 state_id = host_request.moderation_state_id 

994 

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

996 queue_item = session.execute( 

997 select(ModerationQueueItem) 

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

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

1000 ).scalar_one() 

1001 queue_item_id = queue_item.id 

1002 

1003 # Verify it's in the queue 

1004 unresolved_items = ( 

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

1006 .scalars() 

1007 .all() 

1008 ) 

1009 

1010 assert len(unresolved_items) >= 1 

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

1012 

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

1014 with real_moderation_session(moderator_token) as api: 

1015 api.ModerateContent( 

1016 moderation_pb2.ModerateContentReq( 

1017 moderation_state_id=state_id, 

1018 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1019 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1020 reason="Content approved", 

1021 clear_flags=True, 

1022 ) 

1023 ) 

1024 

1025 # Verify queue item was resolved 

1026 with session_scope() as session: 

1027 # Verify it's no longer in unresolved queue 

1028 unresolved_items = ( 

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

1030 .scalars() 

1031 .all() 

1032 ) 

1033 

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

1035 

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

1037 queue_item = session.get_one(ModerationQueueItem, queue_item_id) 

1038 assert queue_item.resolved_by_log_id is not None 

1039 

1040 

1041# ============================================================================ 

1042# Moderation API Tests (testing the gRPC servicer) 

1043# ============================================================================ 

1044 

1045 

1046def test_GetModerationQueue_empty(db): 

1047 """Test getting an empty moderation queue""" 

1048 super_user, super_token = generate_user(is_superuser=True) 

1049 

1050 with real_moderation_session(super_token) as api: 

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

1052 assert len(res.queue_items) == 0 

1053 assert res.next_page_token == "" 

1054 

1055 

1056def test_GetModerationQueue_with_items(db): 

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

1058 super_user, super_token = generate_user(is_superuser=True) 

1059 normal_user, user_token = generate_user() 

1060 host, _ = generate_user() 

1061 

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

1063 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1064 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1065 

1066 with real_moderation_session(super_token) as api: 

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

1068 assert len(res.queue_items) == 2 

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

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

1071 

1072 

1073def test_GetModerationQueue_filter_by_trigger(db): 

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

1075 super_user, super_token = generate_user(is_superuser=True) 

1076 normal_user, user_token = generate_user() 

1077 host, _ = generate_user() 

1078 

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

1080 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1081 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1082 

1083 # Add USER_FLAG trigger to second item via API 

1084 with real_moderation_session(super_token) as api: 

1085 api.ModerateContent( 

1086 moderation_pb2.ModerateContentReq( 

1087 moderation_state_id=state2_id, 

1088 action=moderation_pb2.MODERATION_ACTION_FLAG, 

1089 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

1090 reason="Reported by user", 

1091 ) 

1092 ) 

1093 

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

1095 with real_moderation_session(super_token) as api: 

1096 res = api.GetModerationQueue( 

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

1098 ) 

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

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

1101 

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

1103 with real_moderation_session(super_token) as api: 

1104 res = api.GetModerationQueue( 

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

1106 ) 

1107 assert len(res.queue_items) == 1 

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

1109 

1110 

1111def test_GetModerationQueue_filter_created_before(db): 

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

1113 super_user, super_token = generate_user(is_superuser=True) 

1114 normal_user, user_token = generate_user() 

1115 host, _ = generate_user() 

1116 

1117 # Create host requests 

1118 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1119 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1120 

1121 # Backdate the first queue item 

1122 with session_scope() as session: 

1123 queue_item1 = session.execute( 

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

1125 ).scalar_one() 

1126 # Set it to 2 hours ago 

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

1128 

1129 # The second item remains at current time 

1130 

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

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

1133 with real_moderation_session(super_token) as api: 

1134 res = api.GetModerationQueue( 

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

1136 ) 

1137 assert len(res.queue_items) == 1 

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

1139 

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

1141 with real_moderation_session(super_token) as api: 

1142 res = api.GetModerationQueue( 

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

1144 ) 

1145 assert len(res.queue_items) == 2 

1146 

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

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

1149 with real_moderation_session(super_token) as api: 

1150 res = api.GetModerationQueue( 

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

1152 ) 

1153 assert len(res.queue_items) == 0 

1154 

1155 

1156def test_GetModerationQueue_filter_created_after(db): 

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

1158 super_user, super_token = generate_user(is_superuser=True) 

1159 normal_user, user_token = generate_user() 

1160 host, _ = generate_user() 

1161 

1162 # Create host requests 

1163 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1164 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1165 

1166 # Backdate the first queue item to 2 hours ago 

1167 with session_scope() as session: 

1168 queue_item1 = session.execute( 

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

1170 ).scalar_one() 

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

1172 

1173 # The second item remains at current time 

1174 

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

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

1177 with real_moderation_session(super_token) as api: 

1178 res = api.GetModerationQueue( 

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

1180 ) 

1181 assert len(res.queue_items) == 1 

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

1183 

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

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

1186 with real_moderation_session(super_token) as api: 

1187 res = api.GetModerationQueue( 

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

1189 ) 

1190 assert len(res.queue_items) == 2 

1191 

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

1193 with real_moderation_session(super_token) as api: 

1194 res = api.GetModerationQueue( 

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

1196 ) 

1197 assert len(res.queue_items) == 0 

1198 

1199 

1200def test_GetModerationQueue_filter_created_before_and_after(db): 

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

1202 super_user, super_token = generate_user(is_superuser=True) 

1203 normal_user, user_token = generate_user() 

1204 host, _ = generate_user() 

1205 

1206 # Create 3 host requests 

1207 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1208 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1209 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1210 

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

1212 with session_scope() as session: 

1213 queue_item1 = session.execute( 

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

1215 ).scalar_one() 

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

1217 

1218 queue_item2 = session.execute( 

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

1220 ).scalar_one() 

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

1222 

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

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

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

1226 with real_moderation_session(super_token) as api: 

1227 res = api.GetModerationQueue( 

1228 moderation_pb2.GetModerationQueueReq( 

1229 created_after=Timestamp_from_datetime(after_cutoff), 

1230 created_before=Timestamp_from_datetime(before_cutoff), 

1231 ) 

1232 ) 

1233 assert len(res.queue_items) == 1 

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

1235 

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

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

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

1239 with real_moderation_session(super_token) as api: 

1240 res = api.GetModerationQueue( 

1241 moderation_pb2.GetModerationQueueReq( 

1242 created_after=Timestamp_from_datetime(after_cutoff), 

1243 created_before=Timestamp_from_datetime(before_cutoff), 

1244 ) 

1245 ) 

1246 assert len(res.queue_items) == 1 

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

1248 

1249 

1250def test_GetModerationQueue_filter_unresolved(db): 

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

1252 super_user, super_token = generate_user(is_superuser=True) 

1253 normal_user, user_token = generate_user() 

1254 host, _ = generate_user() 

1255 

1256 # Create 2 host requests 

1257 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1258 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1259 

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

1261 with real_moderation_session(super_token) as api: 

1262 api.ModerateContent( 

1263 moderation_pb2.ModerateContentReq( 

1264 moderation_state_id=state1_id, 

1265 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1266 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1267 reason="Approved", 

1268 clear_flags=True, 

1269 ) 

1270 ) 

1271 

1272 # Get all items 

1273 with real_moderation_session(super_token) as api: 

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

1275 assert len(res.queue_items) == 2 

1276 

1277 # Get only unresolved items 

1278 with real_moderation_session(super_token) as api: 

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

1280 assert len(res.queue_items) == 1 

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

1282 

1283 

1284def test_GetModerationQueue_filter_by_author(db): 

1285 """Test filtering moderation queue by item_author_user_id""" 

1286 super_user, super_token = generate_user(is_superuser=True) 

1287 user1, token1 = generate_user() 

1288 user2, token2 = generate_user() 

1289 host_user, _ = generate_user() 

1290 

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

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

1293 

1294 # Create 2 host requests by user1 

1295 with requests_session(token1) as api: 

1296 hr1_id = api.CreateHostRequest( 

1297 requests_pb2.CreateHostRequestReq( 

1298 host_user_id=host_user.id, 

1299 from_date=today_plus_2, 

1300 to_date=today_plus_3, 

1301 text=valid_request_text(), 

1302 ) 

1303 ).host_request_id 

1304 backdate_conversations() 

1305 

1306 hr2_id = api.CreateHostRequest( 

1307 requests_pb2.CreateHostRequestReq( 

1308 host_user_id=host_user.id, 

1309 from_date=today_plus_2, 

1310 to_date=today_plus_3, 

1311 text=valid_request_text(), 

1312 ) 

1313 ).host_request_id 

1314 

1315 # Create 1 host request by user2 

1316 with requests_session(token2) as api: 

1317 hr3_id = api.CreateHostRequest( 

1318 requests_pb2.CreateHostRequestReq( 

1319 host_user_id=host_user.id, 

1320 from_date=today_plus_2, 

1321 to_date=today_plus_3, 

1322 text=valid_request_text(), 

1323 ) 

1324 ).host_request_id 

1325 

1326 # Get moderation state IDs 

1327 state1_id, state2_id, state3_id = None, None, None 

1328 with session_scope() as session: 

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

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

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

1332 state1_id = hr1.moderation_state_id 

1333 state2_id = hr2.moderation_state_id 

1334 state3_id = hr3.moderation_state_id 

1335 

1336 # Get all items (should be 3) 

1337 with real_moderation_session(super_token) as api: 

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

1339 assert len(res.queue_items) == 3 

1340 

1341 # Filter by user1 (should get 2) 

1342 with real_moderation_session(super_token) as api: 

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

1344 assert len(res.queue_items) == 2 

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

1346 

1347 # Filter by user2 (should get 1) 

1348 with real_moderation_session(super_token) as api: 

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

1350 assert len(res.queue_items) == 1 

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

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

1353 

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

1355 with real_moderation_session(super_token) as api: 

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

1357 assert len(res.queue_items) == 0 

1358 

1359 

1360def test_GetModerationQueue_ordering(db): 

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

1362 super_user, super_token = generate_user(is_superuser=True) 

1363 normal_user, user_token = generate_user() 

1364 host, _ = generate_user() 

1365 

1366 # Create 3 host requests 

1367 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1368 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1369 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1370 

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

1372 with session_scope() as session: 

1373 queue_item1 = session.execute( 

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

1375 ).scalar_one() 

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

1377 

1378 queue_item2 = session.execute( 

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

1380 ).scalar_one() 

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

1382 

1383 queue_item3 = session.execute( 

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

1385 ).scalar_one() 

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

1387 

1388 # Default order (oldest first) 

1389 with real_moderation_session(super_token) as api: 

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

1391 assert len(res.queue_items) == 3 

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

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

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

1395 

1396 # Explicit oldest first 

1397 with real_moderation_session(super_token) as api: 

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

1399 assert len(res.queue_items) == 3 

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

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

1402 

1403 # Newest first 

1404 with real_moderation_session(super_token) as api: 

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

1406 assert len(res.queue_items) == 3 

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

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

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

1410 

1411 

1412def test_GetModerationQueue_pagination_newest_first(db): 

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

1414 super_user, super_token = generate_user(is_superuser=True) 

1415 normal_user, normal_token = generate_user() 

1416 host_user, _ = generate_user() 

1417 

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

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

1420 

1421 # Create 5 host requests 

1422 hr_ids = [] 

1423 with requests_session(normal_token) as api: 

1424 for i in range(5): 

1425 hr_id = api.CreateHostRequest( 

1426 requests_pb2.CreateHostRequestReq( 

1427 host_user_id=host_user.id, 

1428 from_date=today_plus_2, 

1429 to_date=today_plus_3, 

1430 text=valid_request_text(), 

1431 ) 

1432 ).host_request_id 

1433 hr_ids.append(hr_id) 

1434 backdate_conversations() 

1435 

1436 # Get moderation state IDs 

1437 state_ids = [] 

1438 with session_scope() as session: 

1439 for hr_id in hr_ids: 

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

1441 state_ids.append(hr.moderation_state_id) 

1442 

1443 # Set different times so ordering is deterministic 

1444 with session_scope() as session: 

1445 for i, state_id in enumerate(state_ids): 

1446 queue_item = session.execute( 

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

1448 ).scalar_one() 

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

1450 

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

1452 with real_moderation_session(super_token) as api: 

1453 res1 = api.GetModerationQueue( 

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

1455 ) 

1456 assert len(res1.queue_items) == 2 

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

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

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

1460 assert res1.next_page_token # should have more pages 

1461 

1462 # Get second page using the token 

1463 res2 = api.GetModerationQueue( 

1464 moderation_pb2.GetModerationQueueReq( 

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

1466 ) 

1467 ) 

1468 assert len(res2.queue_items) == 2 

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

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

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

1472 

1473 # Pages should not overlap 

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

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

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

1477 

1478 

1479def test_GetModerationQueue_pagination_ignores_time_created(db): 

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

1481 super_user, super_token = generate_user(is_superuser=True) 

1482 normal_user, normal_token = generate_user() 

1483 host, _ = generate_user() 

1484 

1485 first_state_id = create_test_host_request_with_moderation(normal_token, host.id) 

1486 create_test_host_request_with_moderation(normal_token, host.id) 

1487 create_test_host_request_with_moderation(normal_token, host.id) 

1488 

1489 with session_scope() as session: 

1490 item = session.execute( 

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

1492 ).scalar_one() 

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

1494 

1495 paged_ids = [] 

1496 page_token = "" 

1497 with real_moderation_session(super_token) as api: 

1498 while True: 

1499 res = api.GetModerationQueue( 

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

1501 ) 

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

1503 page_token = res.next_page_token 

1504 if not page_token: 

1505 break 

1506 

1507 with session_scope() as session: 

1508 expected = list( 

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

1510 ) 

1511 

1512 assert paged_ids == expected 

1513 

1514 

1515def test_GetModerationLog(db): 

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

1517 super_user, super_token = generate_user(is_superuser=True) 

1518 moderator, moderator_token = generate_user(is_superuser=True) 

1519 normal_user, user_token = generate_user() 

1520 host, _ = generate_user() 

1521 

1522 # Create a real host request 

1523 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1524 

1525 # Perform a moderation action via API 

1526 with real_moderation_session(moderator_token) as api: 

1527 api.ModerateContent( 

1528 moderation_pb2.ModerateContentReq( 

1529 moderation_state_id=state_id, 

1530 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1531 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1532 reason="Looks good", 

1533 ) 

1534 ) 

1535 

1536 with real_moderation_session(super_token) as api: 

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

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

1539 assert res.moderation_state.moderation_state_id == state_id 

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

1541 # Log entries are in reverse chronological order 

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

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

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

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

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

1547 

1548 

1549def test_GetModerationLog_not_found(db): 

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

1551 super_user, super_token = generate_user(is_superuser=True) 

1552 

1553 with real_moderation_session(super_token) as api: 

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

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

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

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

1558 

1559 

1560def test_GetModerationState(db): 

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

1562 super_user, super_token = generate_user(is_superuser=True) 

1563 user1, token1 = generate_user() 

1564 user2, _ = generate_user() 

1565 

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

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

1568 

1569 with requests_session(token1) as api: 

1570 host_request_id = api.CreateHostRequest( 

1571 requests_pb2.CreateHostRequestReq( 

1572 host_user_id=user2.id, 

1573 from_date=today_plus_2, 

1574 to_date=today_plus_3, 

1575 text=valid_request_text(), 

1576 ) 

1577 ).host_request_id 

1578 

1579 with real_moderation_session(super_token) as api: 

1580 res = api.GetModerationState( 

1581 moderation_pb2.GetModerationStateReq( 

1582 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1583 object_id=host_request_id, 

1584 ) 

1585 ) 

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

1587 assert res.moderation_state.object_id == host_request_id 

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

1589 assert res.moderation_state.moderation_state_id > 0 

1590 

1591 

1592def test_GetModerationState_not_found(db): 

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

1594 super_user, super_token = generate_user(is_superuser=True) 

1595 

1596 with real_moderation_session(super_token) as api: 

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

1598 api.GetModerationState( 

1599 moderation_pb2.GetModerationStateReq( 

1600 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1601 object_id=999999, 

1602 ) 

1603 ) 

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

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

1606 

1607 

1608def test_GetModerationState_unspecified_type(db): 

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

1610 super_user, super_token = generate_user(is_superuser=True) 

1611 

1612 with real_moderation_session(super_token) as api: 

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

1614 api.GetModerationState( 

1615 moderation_pb2.GetModerationStateReq( 

1616 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED, 

1617 object_id=123, 

1618 ) 

1619 ) 

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

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

1622 

1623 

1624def test_ModerateContent_approve(db): 

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

1626 super_user, super_token = generate_user(is_superuser=True) 

1627 user1, token1 = generate_user() 

1628 user2, _ = generate_user() 

1629 

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

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

1632 

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

1634 with requests_session(token1) as api: 

1635 host_request_id = api.CreateHostRequest( 

1636 requests_pb2.CreateHostRequestReq( 

1637 host_user_id=user2.id, 

1638 from_date=today_plus_2, 

1639 to_date=today_plus_3, 

1640 text=valid_request_text(), 

1641 ) 

1642 ).host_request_id 

1643 

1644 # Get the moderation state ID 

1645 state_id = None 

1646 with session_scope() as session: 

1647 host_request = session.execute( 

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

1649 ).scalar_one() 

1650 state_id = host_request.moderation_state_id 

1651 

1652 with real_moderation_session(super_token) as api: 

1653 res = api.ModerateContent( 

1654 moderation_pb2.ModerateContentReq( 

1655 moderation_state_id=state_id, 

1656 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1657 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1658 reason="Approved by admin", 

1659 ) 

1660 ) 

1661 assert res.moderation_state.moderation_state_id == state_id 

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

1663 

1664 # Verify state was updated in database 

1665 with session_scope() as session: 

1666 state = session.get_one(ModerationState, state_id) 

1667 assert state.visibility == ModerationVisibility.visible 

1668 

1669 

1670def test_ModerateContent_not_found(db): 

1671 """Test moderating non-existent content""" 

1672 super_user, super_token = generate_user(is_superuser=True) 

1673 

1674 with real_moderation_session(super_token) as api: 

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

1676 api.ModerateContent( 

1677 moderation_pb2.ModerateContentReq( 

1678 moderation_state_id=999999, 

1679 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1680 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1681 reason="Test", 

1682 ) 

1683 ) 

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

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

1686 

1687 

1688def test_ModerateContent_hide(db): 

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

1690 super_user, super_token = generate_user(is_superuser=True) 

1691 normal_user, user_token = generate_user() 

1692 host, _ = generate_user() 

1693 

1694 # Create a real host request 

1695 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1696 

1697 with real_moderation_session(super_token) as api: 

1698 res = api.ModerateContent( 

1699 moderation_pb2.ModerateContentReq( 

1700 moderation_state_id=state_id, 

1701 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1702 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

1703 reason="Spam content", 

1704 ) 

1705 ) 

1706 assert res.moderation_state.moderation_state_id == state_id 

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

1708 

1709 # Verify state was updated in database 

1710 with session_scope() as session: 

1711 state = session.get_one(ModerationState, state_id) 

1712 assert state.visibility == ModerationVisibility.hidden 

1713 

1714 

1715def test_ModerateContent_shadow(db): 

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

1717 super_user, super_token = generate_user(is_superuser=True) 

1718 normal_user, user_token = generate_user() 

1719 host, _ = generate_user() 

1720 

1721 # Create a real host request 

1722 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1723 

1724 with real_moderation_session(super_token) as api: 

1725 res = api.ModerateContent( 

1726 moderation_pb2.ModerateContentReq( 

1727 moderation_state_id=state_id, 

1728 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1729 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

1730 reason="Needs further review", 

1731 ) 

1732 ) 

1733 assert res.moderation_state.moderation_state_id == state_id 

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

1735 

1736 # Verify state was updated in database 

1737 with session_scope() as session: 

1738 state = session.get_one(ModerationState, state_id) 

1739 assert state.visibility == ModerationVisibility.shadowed 

1740 

1741 

1742def test_ModerateContent_flag(db): 

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

1744 super_user, super_token = generate_user(is_superuser=True) 

1745 user1, token1 = generate_user() 

1746 user2, _ = generate_user() 

1747 

1748 # Create a host request 

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

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

1751 

1752 with requests_session(token1) as api: 

1753 host_request_id = api.CreateHostRequest( 

1754 requests_pb2.CreateHostRequestReq( 

1755 host_user_id=user2.id, 

1756 from_date=today_plus_2, 

1757 to_date=today_plus_3, 

1758 text=valid_request_text(), 

1759 ) 

1760 ).host_request_id 

1761 

1762 # Get the moderation state ID 

1763 with session_scope() as session: 

1764 host_request = session.execute( 

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

1766 ).scalar_one() 

1767 state_id = host_request.moderation_state_id 

1768 

1769 with real_moderation_session(super_token) as api: 

1770 api.ModerateContent( 

1771 moderation_pb2.ModerateContentReq( 

1772 moderation_state_id=state_id, 

1773 action=moderation_pb2.MODERATION_ACTION_FLAG, 

1774 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

1775 reason="Admin flagged for additional review", 

1776 priority=5, 

1777 ) 

1778 ) 

1779 

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

1781 with session_scope() as session: 

1782 queue_item = session.execute( 

1783 select(ModerationQueueItem) 

1784 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

1786 ).scalar_one() 

1787 assert queue_item.resolved_by_log_id is None 

1788 assert queue_item.priority == 5 

1789 

1790 flag_log = session.execute( 

1791 select(ModerationLog) 

1792 .where(ModerationLog.moderation_state_id == state_id) 

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

1794 ).scalar_one() 

1795 assert flag_log.queue_item_id == queue_item.id 

1796 

1797 

1798def test_ModerateContent_flag_requires_trigger(db): 

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

1800 super_user, super_token = generate_user(is_superuser=True) 

1801 user1, token1 = generate_user() 

1802 user2, _ = generate_user() 

1803 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1804 

1805 with real_moderation_session(super_token) as api: 

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

1807 api.ModerateContent( 

1808 moderation_pb2.ModerateContentReq( 

1809 moderation_state_id=state_id, 

1810 action=moderation_pb2.MODERATION_ACTION_FLAG, 

1811 reason="no trigger", 

1812 ) 

1813 ) 

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

1815 

1816 

1817def _open_queue_item_id(state_id): 

1818 with session_scope() as session: 

1819 return ( 

1820 session.execute( 

1821 select(ModerationQueueItem) 

1822 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

1824 ) 

1825 .scalars() 

1826 .one() 

1827 .id 

1828 ) 

1829 

1830 

1831def test_ModerateContent_set_priority(db): 

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

1833 moderator, mod_token = generate_user(is_superuser=True) 

1834 user1, token1 = generate_user() 

1835 user2, _ = generate_user() 

1836 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1837 queue_item_id = _open_queue_item_id(state_id) 

1838 

1839 with real_moderation_session(mod_token) as api: 

1840 api.ModerateContent( 

1841 moderation_pb2.ModerateContentReq( 

1842 moderation_state_id=state_id, 

1843 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY, 

1844 queue_item_id=queue_item_id, 

1845 priority=10, 

1846 reason="bump", 

1847 ) 

1848 ) 

1849 

1850 with session_scope() as session: 

1851 queue_item = session.get_one(ModerationQueueItem, queue_item_id) 

1852 assert queue_item.priority == 10 

1853 assert queue_item.resolved_by_log_id is None # not resolved 

1854 # State stays shadowed (visibility untouched) 

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

1856 

1857 log = session.execute( 

1858 select(ModerationLog) 

1859 .where(ModerationLog.moderation_state_id == state_id) 

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

1861 ).scalar_one() 

1862 assert log.new_priority == 10 

1863 assert log.queue_item_id == queue_item_id 

1864 

1865 # GetModerationQueue surfaces the new priority 

1866 with real_moderation_session(mod_token) as api: 

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

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

1869 assert item.priority == 10 

1870 

1871 

1872def test_ModerateContent_unflag(db): 

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

1874 moderator, mod_token = generate_user(is_superuser=True) 

1875 user1, token1 = generate_user() 

1876 user2, _ = generate_user() 

1877 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1878 queue_item_id = _open_queue_item_id(state_id) 

1879 

1880 with real_moderation_session(mod_token) as api: 

1881 api.ModerateContent( 

1882 moderation_pb2.ModerateContentReq( 

1883 moderation_state_id=state_id, 

1884 action=moderation_pb2.MODERATION_ACTION_UNFLAG, 

1885 queue_item_id=queue_item_id, 

1886 reason="dismissed", 

1887 ) 

1888 ) 

1889 

1890 with session_scope() as session: 

1891 queue_item = session.get_one(ModerationQueueItem, queue_item_id) 

1892 assert queue_item.resolved_by_log_id is not None 

1893 # Visibility unchanged (still shadowed from creation) 

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

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

1896 assert log.action == ModerationAction.unflag 

1897 assert log.queue_item_id == queue_item_id 

1898 

1899 

1900def test_ModerateContent_unflag_requires_queue_item(db): 

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

1902 moderator, mod_token = generate_user(is_superuser=True) 

1903 user1, token1 = generate_user() 

1904 user2, _ = generate_user() 

1905 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1906 

1907 with real_moderation_session(mod_token) as api: 

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

1909 api.ModerateContent( 

1910 moderation_pb2.ModerateContentReq( 

1911 moderation_state_id=state_id, 

1912 action=moderation_pb2.MODERATION_ACTION_UNFLAG, 

1913 reason="no target", 

1914 ) 

1915 ) 

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

1917 

1918 

1919def test_ModerateContent_approve_without_clear_flags_leaves_flag_open(db): 

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

1921 moderator, mod_token = generate_user(is_superuser=True) 

1922 user1, token1 = generate_user() 

1923 user2, _ = generate_user() 

1924 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1925 queue_item_id = _open_queue_item_id(state_id) 

1926 

1927 with real_moderation_session(mod_token) as api: 

1928 api.ModerateContent( 

1929 moderation_pb2.ModerateContentReq( 

1930 moderation_state_id=state_id, 

1931 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1932 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1933 reason="visible but still under review", 

1934 ) 

1935 ) 

1936 

1937 with session_scope() as session: 

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

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

1940 

1941 

1942def test_ModerateContent_flag_supersede(db): 

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

1944 moderator, mod_token = generate_user(is_superuser=True) 

1945 user1, token1 = generate_user() 

1946 user2, _ = generate_user() 

1947 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1948 initial_item_id = _open_queue_item_id(state_id) 

1949 

1950 with real_moderation_session(mod_token) as api: 

1951 api.ModerateContent( 

1952 moderation_pb2.ModerateContentReq( 

1953 moderation_state_id=state_id, 

1954 action=moderation_pb2.MODERATION_ACTION_FLAG, 

1955 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG, 

1956 priority=3, 

1957 supersede_queue_item_id=initial_item_id, 

1958 reason="machine re-flag", 

1959 ) 

1960 ) 

1961 

1962 with session_scope() as session: 

1963 # Old initial_review flag is resolved 

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

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

1966 open_items = ( 

1967 session.execute( 

1968 select(ModerationQueueItem) 

1969 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

1971 ) 

1972 .scalars() 

1973 .all() 

1974 ) 

1975 assert len(open_items) == 1 

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

1977 assert open_items[0].priority == 3 

1978 

1979 

1980def test_GetModerationQueue_filter_by_priority(db): 

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

1982 moderator, mod_token = generate_user(is_superuser=True) 

1983 user1, token1 = generate_user() 

1984 user2, _ = generate_user() 

1985 state_id = create_test_host_request_with_moderation(token1, user2.id) 

1986 queue_item_id = _open_queue_item_id(state_id) 

1987 

1988 with real_moderation_session(mod_token) as api: 

1989 api.ModerateContent( 

1990 moderation_pb2.ModerateContentReq( 

1991 moderation_state_id=state_id, 

1992 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY, 

1993 queue_item_id=queue_item_id, 

1994 priority=7, 

1995 reason="raise", 

1996 ) 

1997 ) 

1998 

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

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

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

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

2003 

2004 # priority_max excludes it above the threshold 

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

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

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

2008 

2009 

2010# ============================================================================ 

2011# Tests for group chat moderation 

2012# ============================================================================ 

2013 

2014 

2015def test_group_chat_created_with_moderation_state(db): 

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

2017 user1, token1 = generate_user() 

2018 user2, _ = generate_user() 

2019 make_friends(user1, user2) 

2020 

2021 with conversations_session(token1) as api: 

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

2023 group_chat_id = res.group_chat_id 

2024 

2025 # Verify moderation state was created 

2026 with session_scope() as session: 

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

2028 

2029 assert group_chat.moderation_state.object_type == ModerationObjectType.group_chat 

2030 assert group_chat.moderation_state.object_id == group_chat_id 

2031 # Group chats start as SHADOWED 

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

2033 

2034 # A moderation queue item should have been created 

2035 queue_item = ( 

2036 session.execute( 

2037 select(ModerationQueueItem).where( 

2038 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id 

2039 ) 

2040 ) 

2041 .scalars() 

2042 .first() 

2043 ) 

2044 assert queue_item is not None 

2045 assert queue_item.trigger == ModerationTrigger.initial_review 

2046 

2047 

2048def test_group_chat_GetModerationState(db): 

2049 """Test GetModerationState API for group chats""" 

2050 user1, token1 = generate_user() 

2051 user2, _ = generate_user() 

2052 moderator, mod_token = generate_user(is_superuser=True) 

2053 make_friends(user1, user2) 

2054 

2055 with conversations_session(token1) as api: 

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

2057 group_chat_id = res.group_chat_id 

2058 

2059 # Moderator can look up the moderation state 

2060 with real_moderation_session(mod_token) as api: 

2061 res = api.GetModerationState( 

2062 moderation_pb2.GetModerationStateReq( 

2063 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

2064 object_id=group_chat_id, 

2065 ) 

2066 ) 

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

2068 assert res.moderation_state.object_id == group_chat_id 

2069 # Starts as SHADOWED 

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

2071 

2072 

2073def test_group_chat_moderation_hide(db): 

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

2075 user1, token1 = generate_user() 

2076 user2, token2 = generate_user() 

2077 moderator, mod_token = generate_user(is_superuser=True) 

2078 make_friends(user1, user2) 

2079 

2080 with conversations_session(token1) as api: 

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

2082 group_chat_id = res.group_chat_id 

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

2084 

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

2086 with real_moderation_session(mod_token) as api: 

2087 state_res = api.GetModerationState( 

2088 moderation_pb2.GetModerationStateReq( 

2089 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

2090 object_id=group_chat_id, 

2091 ) 

2092 ) 

2093 api.ModerateContent( 

2094 moderation_pb2.ModerateContentReq( 

2095 moderation_state_id=state_res.moderation_state.moderation_state_id, 

2096 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

2097 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

2098 reason="Approved", 

2099 ) 

2100 ) 

2101 

2102 # Both users can see the chat now 

2103 with conversations_session(token1) as api: 

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

2105 assert len(res.group_chats) == 1 

2106 

2107 with conversations_session(token2) as api: 

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

2109 assert len(res.group_chats) == 1 

2110 

2111 # Moderator hides the group chat 

2112 with real_moderation_session(mod_token) as api: 

2113 state_res = api.GetModerationState( 

2114 moderation_pb2.GetModerationStateReq( 

2115 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

2116 object_id=group_chat_id, 

2117 ) 

2118 ) 

2119 api.ModerateContent( 

2120 moderation_pb2.ModerateContentReq( 

2121 moderation_state_id=state_res.moderation_state.moderation_state_id, 

2122 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2123 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2124 reason="Inappropriate content", 

2125 ) 

2126 ) 

2127 

2128 # Neither user can see the chat now 

2129 with conversations_session(token1) as api: 

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

2131 assert len(res.group_chats) == 0 

2132 

2133 with conversations_session(token2) as api: 

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

2135 assert len(res.group_chats) == 0 

2136 

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

2138 with conversations_session(token1) as api: 

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

2140 assert len(res.messages) == 0 

2141 

2142 

2143def test_group_chat_moderation_shadow(db): 

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

2145 user1, token1 = generate_user() # Creator 

2146 user2, token2 = generate_user() # Participant 

2147 moderator, mod_token = generate_user(is_superuser=True) 

2148 make_friends(user1, user2) 

2149 

2150 with conversations_session(token1) as api: 

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

2152 group_chat_id = res.group_chat_id 

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

2154 

2155 # Moderator shadows the group chat 

2156 with real_moderation_session(mod_token) as api: 

2157 state_res = api.GetModerationState( 

2158 moderation_pb2.GetModerationStateReq( 

2159 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

2160 object_id=group_chat_id, 

2161 ) 

2162 ) 

2163 api.ModerateContent( 

2164 moderation_pb2.ModerateContentReq( 

2165 moderation_state_id=state_res.moderation_state.moderation_state_id, 

2166 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2167 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

2168 reason="Needs review", 

2169 ) 

2170 ) 

2171 

2172 # Creator can see SHADOWED content in list operations 

2173 with conversations_session(token1) as api: 

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

2175 assert len(res.group_chats) == 1 

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

2177 

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

2179 with conversations_session(token2) as api: 

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

2181 assert len(res.group_chats) == 0 

2182 

2183 # Creator can also access it directly via GetGroupChat 

2184 with conversations_session(token1) as api: 

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

2186 assert res.group_chat_id == group_chat_id 

2187 

2188 

2189# ============================================================================ 

2190# Tests for auto-approval background job 

2191# ============================================================================ 

2192 

2193 

2194def test_auto_approve_moderation_queue_disabled_when_zero(db, email_collector: EmailCollector): 

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

2196 moderator, mod_token = generate_user(is_superuser=True) 

2197 user1, token1 = generate_user() 

2198 user2, token2 = generate_user() 

2199 

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

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

2202 

2203 # Create a host request 

2204 with requests_session(token1) as api: 

2205 host_request_id = api.CreateHostRequest( 

2206 requests_pb2.CreateHostRequestReq( 

2207 host_user_id=user2.id, 

2208 from_date=today_plus_2, 

2209 to_date=today_plus_3, 

2210 text=valid_request_text(), 

2211 ) 

2212 ).host_request_id 

2213 

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

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

2216 

2217 # Ensure deadline is 0 (disabled) 

2218 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 0 

2219 

2220 # Run the job 

2221 auto_approve_moderation_queue(empty_pb2.Empty()) 

2222 

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

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

2225 assert res.host_request_id == host_request_id 

2226 

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

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

2229 assert len(res.host_requests) == 1 

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

2231 

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

2233 with requests_session(token2) as api: 

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

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

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

2237 

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

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

2240 assert len(res.host_requests) == 0 

2241 

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

2243 with real_moderation_session(mod_token) as api: 

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

2245 assert len(res.queue_items) == 1 

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

2247 

2248 # Moderator can check the state is still SHADOWED 

2249 state_res = api.GetModerationState( 

2250 moderation_pb2.GetModerationStateReq( 

2251 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2252 object_id=host_request_id, 

2253 ) 

2254 ) 

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

2256 

2257 

2258def test_auto_approve_moderation_queue_approves_old_items( 

2259 db, email_collector: EmailCollector, push_collector: PushCollector 

2260): 

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

2262 moderator, mod_token = generate_user(is_superuser=True) 

2263 user1, token1 = generate_user() 

2264 user2, token2 = generate_user() 

2265 

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

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

2268 

2269 # Create a host request 

2270 with requests_session(token1) as api: 

2271 host_request_id = api.CreateHostRequest( 

2272 requests_pb2.CreateHostRequestReq( 

2273 host_user_id=user2.id, 

2274 from_date=today_plus_2, 

2275 to_date=today_plus_3, 

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

2277 ) 

2278 ).host_request_id 

2279 

2280 # No email sent initially (shadowed) 

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

2282 

2283 # Host cannot see the request yet 

2284 with requests_session(token2) as api: 

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

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

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

2288 

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

2290 with session_scope() as session: 

2291 host_request = session.execute( 

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

2293 ).scalar_one() 

2294 queue_item = session.execute( 

2295 select(ModerationQueueItem) 

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

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

2298 ).scalar_one() 

2299 # Backdate the queue item by 2 minutes 

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

2301 

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

2303 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60 

2304 config.MODERATION_BOT_USER_ID = moderator.id 

2305 

2306 # Run the job 

2307 auto_approve_moderation_queue(empty_pb2.Empty()) 

2308 

2309 # Now host can see the request via API 

2310 with requests_session(token2) as api: 

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

2312 assert res.host_request_id == host_request_id 

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

2314 

2315 # Host sees it in their received list 

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

2317 assert len(res.host_requests) == 1 

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

2319 

2320 # Surfer sees it in their sent list 

2321 with requests_session(token1) as api: 

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

2323 assert len(res.host_requests) == 1 

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

2325 

2326 with real_moderation_session(mod_token) as api: 

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

2328 assert len(res.queue_items) == 1 

2329 flag_item = res.queue_items[0] 

2330 assert flag_item.trigger == moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG 

2331 assert flag_item.priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY 

2332 

2333 state_res = api.GetModerationState( 

2334 moderation_pb2.GetModerationStateReq( 

2335 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2336 object_id=host_request_id, 

2337 ) 

2338 ) 

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

2340 

2341 log_res = api.GetModerationLog( 

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

2343 ) 

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

2345 assert len(approve_entries) == 1 

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

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

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

2349 

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

2351 assert len(flag_entries) == 1 

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

2353 

2354 

2355def test_auto_approve_does_not_approve_recent_items(db, email_collector: EmailCollector): 

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

2357 moderator, mod_token = generate_user(is_superuser=True) 

2358 user1, token1 = generate_user() 

2359 user2, token2 = generate_user() 

2360 

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

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

2363 

2364 # Create a host request 

2365 with requests_session(token1) as api: 

2366 host_request_id = api.CreateHostRequest( 

2367 requests_pb2.CreateHostRequestReq( 

2368 host_user_id=user2.id, 

2369 from_date=today_plus_2, 

2370 to_date=today_plus_3, 

2371 text=valid_request_text(), 

2372 ) 

2373 ).host_request_id 

2374 

2375 # No email sent (shadowed) 

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

2377 

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

2379 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 3600 

2380 config.MODERATION_BOT_USER_ID = moderator.id 

2381 

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

2383 auto_approve_moderation_queue(empty_pb2.Empty()) 

2384 

2385 # Still no email sent 

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

2387 

2388 # Host still cannot see the request 

2389 with requests_session(token2) as api: 

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

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

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

2393 

2394 # Not in host's received list 

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

2396 assert len(res.host_requests) == 0 

2397 

2398 # Moderator sees it still in queue unresolved 

2399 with real_moderation_session(mod_token) as api: 

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

2401 assert len(res.queue_items) == 1 

2402 

2403 # State is still SHADOWED 

2404 state_res = api.GetModerationState( 

2405 moderation_pb2.GetModerationStateReq( 

2406 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2407 object_id=host_request_id, 

2408 ) 

2409 ) 

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

2411 

2412 

2413def test_auto_approve_does_not_approve_already_approved(db): 

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

2415 moderator, mod_token = generate_user(is_superuser=True) 

2416 user1, token1 = generate_user() 

2417 user2, token2 = generate_user() 

2418 

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

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

2421 

2422 # Create a host request 

2423 with requests_session(token1) as api: 

2424 host_request_id = api.CreateHostRequest( 

2425 requests_pb2.CreateHostRequestReq( 

2426 host_user_id=user2.id, 

2427 from_date=today_plus_2, 

2428 to_date=today_plus_3, 

2429 text=valid_request_text(), 

2430 ) 

2431 ).host_request_id 

2432 

2433 # Moderator approves it manually 

2434 with real_moderation_session(mod_token) as api: 

2435 state_res = api.GetModerationState( 

2436 moderation_pb2.GetModerationStateReq( 

2437 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2438 object_id=host_request_id, 

2439 ) 

2440 ) 

2441 state_id = state_res.moderation_state.moderation_state_id 

2442 

2443 api.ModerateContent( 

2444 moderation_pb2.ModerateContentReq( 

2445 moderation_state_id=state_id, 

2446 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

2447 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

2448 reason="Approved by moderator", 

2449 clear_flags=True, 

2450 ) 

2451 ) 

2452 

2453 # Host can now see it 

2454 with requests_session(token2) as api: 

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

2456 assert res.host_request_id == host_request_id 

2457 

2458 # Get log count before auto-approval 

2459 with real_moderation_session(mod_token) as api: 

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

2461 log_count_before = len(log_res_before.log_entries) 

2462 

2463 # Set deadline to 1 second 

2464 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1 

2465 config.MODERATION_BOT_USER_ID = moderator.id 

2466 

2467 # Run the job 

2468 auto_approve_moderation_queue(empty_pb2.Empty()) 

2469 

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

2471 with real_moderation_session(mod_token) as api: 

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

2473 assert len(log_res_after.log_entries) == log_count_before 

2474 

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

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

2477 assert len(queue_res.queue_items) == 0 

2478 

2479 

2480def test_auto_approve_does_not_approve_moderator_shadowed_items(db): 

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

2482 moderator, mod_token = generate_user(is_superuser=True) 

2483 user1, token1 = generate_user() 

2484 user2, token2 = generate_user() 

2485 

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

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

2488 

2489 # Create a host request 

2490 with requests_session(token1) as api: 

2491 host_request_id = api.CreateHostRequest( 

2492 requests_pb2.CreateHostRequestReq( 

2493 host_user_id=user2.id, 

2494 from_date=today_plus_2, 

2495 to_date=today_plus_3, 

2496 text=valid_request_text(), 

2497 ) 

2498 ).host_request_id 

2499 

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

2501 with real_moderation_session(mod_token) as api: 

2502 state_res = api.GetModerationState( 

2503 moderation_pb2.GetModerationStateReq( 

2504 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2505 object_id=host_request_id, 

2506 ) 

2507 ) 

2508 state_id = state_res.moderation_state.moderation_state_id 

2509 

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

2511 api.ModerateContent( 

2512 moderation_pb2.ModerateContentReq( 

2513 moderation_state_id=state_id, 

2514 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2515 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

2516 reason="Keeping shadowed for review", 

2517 clear_flags=True, 

2518 ) 

2519 ) 

2520 

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

2522 with session_scope() as session: 

2523 queue_item = session.execute( 

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

2525 ).scalar_one() 

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

2527 

2528 # Set deadline to 1 second 

2529 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1 

2530 config.MODERATION_BOT_USER_ID = moderator.id 

2531 

2532 # Get log count before 

2533 with real_moderation_session(mod_token) as api: 

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

2535 log_count_before = len(log_res_before.log_entries) 

2536 

2537 # Run the job 

2538 auto_approve_moderation_queue(empty_pb2.Empty()) 

2539 

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

2541 with real_moderation_session(mod_token) as api: 

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

2543 assert len(log_res_after.log_entries) == log_count_before 

2544 

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

2546 state_res = api.GetModerationState( 

2547 moderation_pb2.GetModerationStateReq( 

2548 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2549 object_id=host_request_id, 

2550 ) 

2551 ) 

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

2553 

2554 # Host still cannot see the request 

2555 with requests_session(token2) as api: 

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

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

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

2559 

2560 

2561def test_auto_approve_skips_shadowed_user_authored_items(db): 

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

2563 moderator, mod_token = generate_user(is_superuser=True) 

2564 surfer, surfer_token = generate_user() 

2565 host, host_token = generate_user() 

2566 

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

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

2569 

2570 with requests_session(surfer_token) as api: 

2571 host_request_id = api.CreateHostRequest( 

2572 requests_pb2.CreateHostRequestReq( 

2573 host_user_id=host.id, 

2574 from_date=today_plus_2, 

2575 to_date=today_plus_3, 

2576 text=valid_request_text(), 

2577 ) 

2578 ).host_request_id 

2579 

2580 # Shadow the surfer after the host request was created 

2581 with session_scope() as session: 

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

2583 

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

2585 with session_scope() as session: 

2586 host_request = session.execute( 

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

2588 ).scalar_one() 

2589 queue_item = session.execute( 

2590 select(ModerationQueueItem) 

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

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

2593 ).scalar_one() 

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

2595 

2596 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60 

2597 config.MODERATION_BOT_USER_ID = moderator.id 

2598 

2599 auto_approve_moderation_queue(empty_pb2.Empty()) 

2600 

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

2602 with real_moderation_session(mod_token) as api: 

2603 state_res = api.GetModerationState( 

2604 moderation_pb2.GetModerationStateReq( 

2605 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2606 object_id=host_request_id, 

2607 ) 

2608 ) 

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

2610 

2611 # Host still cannot see the request 

2612 with requests_session(host_token) as api: 

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

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

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

2616 

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

2618 with requests_session(surfer_token) as api: 

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

2620 assert res.host_request_id == host_request_id 

2621 

2622 

2623def test_auto_approve_preserves_other_open_flags(db): 

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

2625 moderator, mod_token = generate_user(is_superuser=True) 

2626 surfer, surfer_token = generate_user() 

2627 host, _ = generate_user() 

2628 

2629 state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

2630 

2631 with real_moderation_session(mod_token) as api: 

2632 api.ModerateContent( 

2633 moderation_pb2.ModerateContentReq( 

2634 moderation_state_id=state_id, 

2635 action=moderation_pb2.MODERATION_ACTION_FLAG, 

2636 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

2637 priority=5, 

2638 reason="user reported this", 

2639 ) 

2640 ) 

2641 

2642 with session_scope() as session: 

2643 initial_item = session.execute( 

2644 select(ModerationQueueItem) 

2645 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

2647 ).scalar_one() 

2648 initial_item_id = initial_item.id 

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

2650 

2651 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60 

2652 config.MODERATION_BOT_USER_ID = moderator.id 

2653 

2654 auto_approve_moderation_queue(empty_pb2.Empty()) 

2655 

2656 with session_scope() as session: 

2657 assert ( 

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

2659 .scalar_one() 

2660 .resolved_by_log_id 

2661 is not None 

2662 ) 

2663 assert ( 

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

2665 == ModerationVisibility.visible 

2666 ) 

2667 

2668 open_items = ( 

2669 session.execute( 

2670 select(ModerationQueueItem) 

2671 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

2673 ) 

2674 .scalars() 

2675 .all() 

2676 ) 

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

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

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

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

2681 

2682 

2683# ============================================================================ 

2684# Notification Suppression Tests 

2685# ============================================================================ 

2686 

2687 

2688def test_host_request_message_notifications_suppressed_before_approval( 

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

2690): 

2691 """ 

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

2693 that haven't been approved yet. 

2694 """ 

2695 host, host_token = generate_user(complete_profile=True) 

2696 surfer, surfer_token = generate_user(complete_profile=True) 

2697 

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

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

2700 

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

2702 with requests_session(surfer_token) as api: 

2703 hr_id = api.CreateHostRequest( 

2704 requests_pb2.CreateHostRequestReq( 

2705 host_user_id=host.id, 

2706 from_date=today_plus_2, 

2707 to_date=today_plus_3, 

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

2709 ) 

2710 ).host_request_id 

2711 

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

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

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

2715 

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

2717 with requests_session(surfer_token) as api: 

2718 api.SendHostRequestMessage( 

2719 requests_pb2.SendHostRequestMessageReq( 

2720 host_request_id=hr_id, 

2721 text="Follow-up message 1", 

2722 ) 

2723 ) 

2724 api.SendHostRequestMessage( 

2725 requests_pb2.SendHostRequestMessageReq( 

2726 host_request_id=hr_id, 

2727 text="Follow-up message 2", 

2728 ) 

2729 ) 

2730 

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

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

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

2734 

2735 # Now approve the request 

2736 moderator.approve_host_request(hr_id) 

2737 email_collector.pop_for_recipient(host.email) 

2738 

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

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

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

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

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

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

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

2746 

2747 

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

2749 """ 

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

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

2752 

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

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

2755 """ 

2756 host, host_token = generate_user(complete_profile=True) 

2757 surfer, surfer_token = generate_user(complete_profile=True) 

2758 

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

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

2761 

2762 # Create host request 

2763 with requests_session(surfer_token) as api: 

2764 hr_id = api.CreateHostRequest( 

2765 requests_pb2.CreateHostRequestReq( 

2766 host_user_id=host.id, 

2767 from_date=today_plus_2, 

2768 to_date=today_plus_3, 

2769 text=valid_request_text(), 

2770 ) 

2771 ).host_request_id 

2772 

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

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

2775 

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

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

2778 with requests_session(surfer_token) as api: 

2779 api.RespondHostRequest( 

2780 requests_pb2.RespondHostRequestReq( 

2781 host_request_id=hr_id, 

2782 status=messages_pb2.HOST_REQUEST_STATUS_CANCELLED, 

2783 text="Actually, never mind", 

2784 ) 

2785 ) 

2786 

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

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

2789 

2790 

2791def test_host_request_notifications_sent_after_approval( 

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

2793): 

2794 """ 

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

2796 """ 

2797 host, host_token = generate_user(complete_profile=True) 

2798 surfer, surfer_token = generate_user(complete_profile=True) 

2799 

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

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

2802 

2803 # Create and approve host request 

2804 with requests_session(surfer_token) as api: 

2805 hr_id = api.CreateHostRequest( 

2806 requests_pb2.CreateHostRequestReq( 

2807 host_user_id=host.id, 

2808 from_date=today_plus_2, 

2809 to_date=today_plus_3, 

2810 text=valid_request_text(), 

2811 ) 

2812 ).host_request_id 

2813 

2814 moderator.approve_host_request(hr_id) 

2815 

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

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

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

2819 

2820 # Host accepts the request - surfer should be notified 

2821 with requests_session(host_token) as api: 

2822 api.RespondHostRequest( 

2823 requests_pb2.RespondHostRequestReq( 

2824 host_request_id=hr_id, 

2825 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

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

2827 ) 

2828 ) 

2829 

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

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

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

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

2834 

2835 # Surfer confirms - host should be notified 

2836 with requests_session(surfer_token) as api: 

2837 api.RespondHostRequest( 

2838 requests_pb2.RespondHostRequestReq( 

2839 host_request_id=hr_id, 

2840 status=messages_pb2.HOST_REQUEST_STATUS_CONFIRMED, 

2841 text="See you then!", 

2842 ) 

2843 ) 

2844 

2845 # Host should now have received the confirmation notifications 

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

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

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

2849 

2850 

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

2852 """ 

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

2854 that haven't been approved yet. 

2855 """ 

2856 user1, token1 = generate_user(complete_profile=True) 

2857 user2, token2 = generate_user(complete_profile=True) 

2858 

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

2860 with conversations_session(token1) as api: 

2861 res = api.CreateGroupChat( 

2862 conversations_pb2.CreateGroupChatReq( 

2863 recipient_user_ids=[user2.id], 

2864 ) 

2865 ) 

2866 gc_id = res.group_chat_id 

2867 

2868 # Verify initial state 

2869 with session_scope() as session: 

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

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

2872 

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

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

2875 

2876 # Send messages BEFORE approval 

2877 with conversations_session(token1) as api: 

2878 api.SendMessage( 

2879 conversations_pb2.SendMessageReq( 

2880 group_chat_id=gc_id, 

2881 text="Hello before approval", 

2882 ) 

2883 ) 

2884 

2885 # Process the queued notification job 

2886 while process_job(): 

2887 pass 

2888 

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

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

2891 

2892 # Now approve the group chat 

2893 moderator.approve_group_chat(gc_id) 

2894 

2895 # Process the queued notification jobs from approval 

2896 while process_job(): 

2897 pass 

2898 

2899 # Verify moderation state after approval 

2900 with session_scope() as session: 

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

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

2903 

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

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

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

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

2908 

2909 # Send a message AFTER approval 

2910 with conversations_session(token1) as api: 

2911 api.SendMessage( 

2912 conversations_pb2.SendMessageReq( 

2913 group_chat_id=gc_id, 

2914 text="Hello after approval", 

2915 ) 

2916 ) 

2917 

2918 # Process the queued notification job 

2919 while process_job(): 

2920 pass 

2921 

2922 # User2 should have received another notification 

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

2924 

2925 

2926def test_event_moderation_state_content(db): 

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

2928 super_user, super_token = generate_user(is_superuser=True) 

2929 user, token = generate_user() 

2930 

2931 with session_scope() as session: 

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

2933 

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

2935 end_time = start_time + timedelta(hours=3) 

2936 

2937 with events_session(token) as api: 

2938 res = api.CreateEvent( 

2939 events_pb2.CreateEventReq( 

2940 title="My Event Title", 

2941 content="My event description.", 

2942 photo_key=None, 

2943 location=events_pb2.EventLocation( 

2944 address="Near Null Island", 

2945 lat=0.1, 

2946 lng=0.2, 

2947 ), 

2948 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time), 

2949 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time), 

2950 ) 

2951 ) 

2952 event_id = res.event_id 

2953 

2954 with session_scope() as session: 

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

2956 state_id = occurrence.moderation_state_id 

2957 

2958 with real_moderation_session(super_token) as api: 

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

2960 event_items = [ 

2961 item 

2962 for item in res.queue_items 

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

2964 ] 

2965 assert len(event_items) == 1 

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

2967 

2968 

2969# ============================================================================ 

2970# Tests for SetUserContentVisibility 

2971# ============================================================================ 

2972 

2973 

2974def _get_moderation_state(session, object_type, object_id): 

2975 return session.execute( 

2976 select(ModerationState) 

2977 .where(ModerationState.object_type == object_type) 

2978 .where(ModerationState.object_id == object_id) 

2979 ).scalar_one() 

2980 

2981 

2982def test_SetUserContentVisibility_host_request(db): 

2983 super_user, super_token = generate_user(is_superuser=True) 

2984 surfer, surfer_token = generate_user() 

2985 host, _ = generate_user() 

2986 

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

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

2989 with requests_session(surfer_token) as api: 

2990 hr_id = api.CreateHostRequest( 

2991 requests_pb2.CreateHostRequestReq( 

2992 host_user_id=host.id, 

2993 from_date=today_plus_2, 

2994 to_date=today_plus_3, 

2995 text=valid_request_text(), 

2996 ) 

2997 ).host_request_id 

2998 

2999 with real_moderation_session(super_token) as api: 

3000 res = api.SetUserContentVisibility( 

3001 moderation_pb2.SetUserContentVisibilityReq( 

3002 user_id=surfer.id, 

3003 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

3004 ) 

3005 ) 

3006 # Already shadowed by default — no-op 

3007 assert res.updated_count == 0 

3008 

3009 with real_moderation_session(super_token) as api: 

3010 res = api.SetUserContentVisibility( 

3011 moderation_pb2.SetUserContentVisibilityReq( 

3012 user_id=surfer.id, 

3013 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3014 reason="policy violation", 

3015 ) 

3016 ) 

3017 assert res.updated_count == 1 

3018 

3019 with session_scope() as session: 

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

3021 assert state.visibility == ModerationVisibility.hidden 

3022 

3023 log_entries = ( 

3024 session.execute( 

3025 select(ModerationLog) 

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

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

3028 ) 

3029 .scalars() 

3030 .all() 

3031 ) 

3032 # create log + bulk update log 

3033 assert len(log_entries) == 2 

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

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

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

3037 

3038 

3039def test_SetUserContentVisibility_group_chat(db): 

3040 super_user, super_token = generate_user(is_superuser=True) 

3041 creator, creator_token = generate_user() 

3042 other, _ = generate_user() 

3043 make_friends(creator, other) 

3044 

3045 with conversations_session(creator_token) as api: 

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

3047 

3048 with real_moderation_session(super_token) as api: 

3049 api.SetUserContentVisibility( 

3050 moderation_pb2.SetUserContentVisibilityReq( 

3051 user_id=creator.id, 

3052 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3053 ) 

3054 ) 

3055 

3056 with session_scope() as session: 

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

3058 assert state.visibility == ModerationVisibility.hidden 

3059 

3060 

3061def test_SetUserContentVisibility_event_occurrence(db): 

3062 super_user, super_token = generate_user(is_superuser=True) 

3063 creator, creator_token = generate_user() 

3064 

3065 with session_scope() as session: 

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

3067 

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

3069 end_time = start_time + timedelta(hours=3) 

3070 with events_session(creator_token) as api: 

3071 event_id = api.CreateEvent( 

3072 events_pb2.CreateEventReq( 

3073 title="Event", 

3074 content="Event description.", 

3075 photo_key=None, 

3076 location=events_pb2.EventLocation( 

3077 address="Near Null Island", 

3078 lat=0.1, 

3079 lng=0.2, 

3080 ), 

3081 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time), 

3082 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time), 

3083 ) 

3084 ).event_id 

3085 

3086 with real_moderation_session(super_token) as api: 

3087 res = api.SetUserContentVisibility( 

3088 moderation_pb2.SetUserContentVisibilityReq( 

3089 user_id=creator.id, 

3090 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3091 ) 

3092 ) 

3093 assert res.updated_count == 1 

3094 

3095 with session_scope() as session: 

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

3097 assert state.visibility == ModerationVisibility.hidden 

3098 

3099 

3100def test_SetUserContentVisibility_friend_request(db): 

3101 super_user, super_token = generate_user(is_superuser=True) 

3102 sender, sender_token = generate_user() 

3103 recipient, _ = generate_user() 

3104 

3105 with api_session(sender_token) as api: 

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

3107 

3108 with session_scope() as session: 

3109 fr_id = session.execute( 

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

3111 ).scalar_one() 

3112 

3113 with real_moderation_session(super_token) as api: 

3114 res = api.SetUserContentVisibility( 

3115 moderation_pb2.SetUserContentVisibilityReq( 

3116 user_id=sender.id, 

3117 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3118 ) 

3119 ) 

3120 assert res.updated_count == 1 

3121 

3122 with session_scope() as session: 

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

3124 assert state.visibility == ModerationVisibility.hidden 

3125 

3126 

3127def test_SetUserContentVisibility_round_trip(db): 

3128 super_user, super_token = generate_user(is_superuser=True) 

3129 surfer, surfer_token = generate_user() 

3130 host, _ = generate_user() 

3131 

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

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

3134 with requests_session(surfer_token) as api: 

3135 hr_id = api.CreateHostRequest( 

3136 requests_pb2.CreateHostRequestReq( 

3137 host_user_id=host.id, 

3138 from_date=today_plus_2, 

3139 to_date=today_plus_3, 

3140 text=valid_request_text(), 

3141 ) 

3142 ).host_request_id 

3143 

3144 with real_moderation_session(super_token) as api: 

3145 api.SetUserContentVisibility( 

3146 moderation_pb2.SetUserContentVisibilityReq( 

3147 user_id=surfer.id, 

3148 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3149 reason="first", 

3150 ) 

3151 ) 

3152 api.SetUserContentVisibility( 

3153 moderation_pb2.SetUserContentVisibilityReq( 

3154 user_id=surfer.id, 

3155 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

3156 reason="second", 

3157 ) 

3158 ) 

3159 

3160 with session_scope() as session: 

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

3162 assert state.visibility == ModerationVisibility.visible 

3163 

3164 bulk_log_entries = ( 

3165 session.execute( 

3166 select(ModerationLog) 

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

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

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

3170 ) 

3171 .scalars() 

3172 .all() 

3173 ) 

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

3175 ModerationVisibility.hidden, 

3176 ModerationVisibility.visible, 

3177 ] 

3178 

3179 

3180def test_SetUserContentVisibility_resolves_queue_items(db): 

3181 super_user, super_token = generate_user(is_superuser=True) 

3182 surfer, surfer_token = generate_user() 

3183 host, _ = generate_user() 

3184 

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

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

3187 with requests_session(surfer_token) as api: 

3188 hr_id = api.CreateHostRequest( 

3189 requests_pb2.CreateHostRequestReq( 

3190 host_user_id=host.id, 

3191 from_date=today_plus_2, 

3192 to_date=today_plus_3, 

3193 text=valid_request_text(), 

3194 ) 

3195 ).host_request_id 

3196 

3197 with session_scope() as session: 

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

3199 queue_item = session.execute( 

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

3201 ).scalar_one() 

3202 assert queue_item.resolved_by_log_id is None 

3203 

3204 with real_moderation_session(super_token) as api: 

3205 api.SetUserContentVisibility( 

3206 moderation_pb2.SetUserContentVisibilityReq( 

3207 user_id=surfer.id, 

3208 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3209 ) 

3210 ) 

3211 

3212 with session_scope() as session: 

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

3214 queue_item = session.execute( 

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

3216 ).scalar_one() 

3217 assert queue_item.resolved_by_log_id is not None 

3218 

3219 

3220def test_SetUserContentVisibility_noop_when_matches(db): 

3221 super_user, super_token = generate_user(is_superuser=True) 

3222 surfer, surfer_token = generate_user() 

3223 host, _ = generate_user() 

3224 

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

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

3227 with requests_session(surfer_token) as api: 

3228 api.CreateHostRequest( 

3229 requests_pb2.CreateHostRequestReq( 

3230 host_user_id=host.id, 

3231 from_date=today_plus_2, 

3232 to_date=today_plus_3, 

3233 text=valid_request_text(), 

3234 ) 

3235 ) 

3236 

3237 with session_scope() as session: 

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

3239 

3240 with real_moderation_session(super_token) as api: 

3241 res = api.SetUserContentVisibility( 

3242 moderation_pb2.SetUserContentVisibilityReq( 

3243 user_id=surfer.id, 

3244 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

3245 ) 

3246 ) 

3247 assert res.updated_count == 0 

3248 

3249 with session_scope() as session: 

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

3251 assert log_count_after == log_count_before 

3252 

3253 

3254def test_SetUserContentVisibility_unspecified_rejected(db): 

3255 super_user, super_token = generate_user(is_superuser=True) 

3256 target, _ = generate_user() 

3257 

3258 with real_moderation_session(super_token) as api: 

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

3260 api.SetUserContentVisibility( 

3261 moderation_pb2.SetUserContentVisibilityReq( 

3262 user_id=target.id, 

3263 visibility=moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED, 

3264 ) 

3265 ) 

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

3267 

3268 

3269def test_SetUserContentVisibility_non_admin_rejected(db): 

3270 normal_user, normal_token = generate_user() 

3271 target, _ = generate_user() 

3272 

3273 with real_moderation_session(normal_token) as api: 

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

3275 api.SetUserContentVisibility( 

3276 moderation_pb2.SetUserContentVisibilityReq( 

3277 user_id=target.id, 

3278 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3279 ) 

3280 ) 

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

3282 

3283 

3284def test_SetUserContentVisibility_writes_admin_action(db): 

3285 super_user, super_token = generate_user(is_superuser=True) 

3286 surfer, surfer_token = generate_user() 

3287 host, _ = generate_user() 

3288 

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

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

3291 with requests_session(surfer_token) as api: 

3292 api.CreateHostRequest( 

3293 requests_pb2.CreateHostRequestReq( 

3294 host_user_id=host.id, 

3295 from_date=today_plus_2, 

3296 to_date=today_plus_3, 

3297 text=valid_request_text(), 

3298 ) 

3299 ) 

3300 

3301 with real_moderation_session(super_token) as api: 

3302 api.SetUserContentVisibility( 

3303 moderation_pb2.SetUserContentVisibilityReq( 

3304 user_id=surfer.id, 

3305 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3306 reason="bulk hide", 

3307 ) 

3308 ) 

3309 

3310 with session_scope() as session: 

3311 actions = ( 

3312 session.execute( 

3313 select(AdminAction) 

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

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

3316 ) 

3317 .scalars() 

3318 .all() 

3319 ) 

3320 assert len(actions) == 1 

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

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

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

3324 

3325 

3326def test_SetUserContentVisibility_only_touches_target(db): 

3327 super_user, super_token = generate_user(is_superuser=True) 

3328 target, target_token = generate_user() 

3329 other, other_token = generate_user() 

3330 host, _ = generate_user() 

3331 

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

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

3334 

3335 with requests_session(target_token) as api: 

3336 target_hr_id = api.CreateHostRequest( 

3337 requests_pb2.CreateHostRequestReq( 

3338 host_user_id=host.id, 

3339 from_date=today_plus_2, 

3340 to_date=today_plus_3, 

3341 text=valid_request_text(), 

3342 ) 

3343 ).host_request_id 

3344 

3345 with requests_session(other_token) as api: 

3346 other_hr_id = api.CreateHostRequest( 

3347 requests_pb2.CreateHostRequestReq( 

3348 host_user_id=host.id, 

3349 from_date=today_plus_2, 

3350 to_date=today_plus_3, 

3351 text=valid_request_text(), 

3352 ) 

3353 ).host_request_id 

3354 

3355 with real_moderation_session(super_token) as api: 

3356 res = api.SetUserContentVisibility( 

3357 moderation_pb2.SetUserContentVisibilityReq( 

3358 user_id=target.id, 

3359 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3360 ) 

3361 ) 

3362 assert res.updated_count == 1 

3363 

3364 with session_scope() as session: 

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

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

3367 assert target_state.visibility == ModerationVisibility.hidden 

3368 assert other_state.visibility == ModerationVisibility.shadowed 

3369 

3370 

3371def test_SetUserContentVisibility_user_not_found(db): 

3372 super_user, super_token = generate_user(is_superuser=True) 

3373 

3374 with real_moderation_session(super_token) as api: 

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

3376 api.SetUserContentVisibility( 

3377 moderation_pb2.SetUserContentVisibilityReq( 

3378 user_id=999999, 

3379 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3380 ) 

3381 ) 

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

3383 

3384 

3385def test_SetUserContentVisibility_from_visibility_filter(db): 

3386 super_user, super_token = generate_user(is_superuser=True) 

3387 surfer, surfer_token = generate_user() 

3388 host, _ = generate_user() 

3389 

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

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

3392 with requests_session(surfer_token) as api: 

3393 hr_id = api.CreateHostRequest( 

3394 requests_pb2.CreateHostRequestReq( 

3395 host_user_id=host.id, 

3396 from_date=today_plus_2, 

3397 to_date=today_plus_3, 

3398 text=valid_request_text(), 

3399 ) 

3400 ).host_request_id 

3401 

3402 with real_moderation_session(super_token) as api: 

3403 api.SetUserContentVisibility( 

3404 moderation_pb2.SetUserContentVisibilityReq( 

3405 user_id=surfer.id, 

3406 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

3407 ) 

3408 ) 

3409 

3410 with real_moderation_session(super_token) as api: 

3411 res = api.SetUserContentVisibility( 

3412 moderation_pb2.SetUserContentVisibilityReq( 

3413 user_id=surfer.id, 

3414 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3415 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_SHADOWED], 

3416 ) 

3417 ) 

3418 assert res.updated_count == 0 

3419 

3420 with session_scope() as session: 

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

3422 assert state.visibility == ModerationVisibility.visible 

3423 

3424 with real_moderation_session(super_token) as api: 

3425 res = api.SetUserContentVisibility( 

3426 moderation_pb2.SetUserContentVisibilityReq( 

3427 user_id=surfer.id, 

3428 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

3429 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_VISIBLE], 

3430 ) 

3431 ) 

3432 assert res.updated_count == 1 

3433 

3434 with session_scope() as session: 

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

3436 assert state.visibility == ModerationVisibility.shadowed 

3437 

3438 

3439def test_SetUserContentVisibility_from_visibility_multi(db): 

3440 super_user, super_token = generate_user(is_superuser=True) 

3441 surfer, surfer_token = generate_user() 

3442 host1, _ = generate_user() 

3443 host2, _ = generate_user() 

3444 

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

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

3447 with requests_session(surfer_token) as api: 

3448 hr1_id = api.CreateHostRequest( 

3449 requests_pb2.CreateHostRequestReq( 

3450 host_user_id=host1.id, 

3451 from_date=today_plus_2, 

3452 to_date=today_plus_3, 

3453 text=valid_request_text(), 

3454 ) 

3455 ).host_request_id 

3456 hr2_id = api.CreateHostRequest( 

3457 requests_pb2.CreateHostRequestReq( 

3458 host_user_id=host2.id, 

3459 from_date=today_plus_2, 

3460 to_date=today_plus_3, 

3461 text=valid_request_text(), 

3462 ) 

3463 ).host_request_id 

3464 

3465 with session_scope() as session: 

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

3467 

3468 with real_moderation_session(super_token) as api: 

3469 api.ModerateContent( 

3470 moderation_pb2.ModerateContentReq( 

3471 moderation_state_id=state1_id, 

3472 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

3473 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

3474 ) 

3475 ) 

3476 

3477 with real_moderation_session(super_token) as api: 

3478 res = api.SetUserContentVisibility( 

3479 moderation_pb2.SetUserContentVisibilityReq( 

3480 user_id=surfer.id, 

3481 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3482 from_visibility=[ 

3483 moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

3484 moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

3485 ], 

3486 ) 

3487 ) 

3488 assert res.updated_count == 2 

3489 

3490 with session_scope() as session: 

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

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

3493 assert state1.visibility == ModerationVisibility.hidden 

3494 assert state2.visibility == ModerationVisibility.hidden 

3495 

3496 

3497def test_SetUserContentVisibility_from_visibility_empty_is_any(db): 

3498 super_user, super_token = generate_user(is_superuser=True) 

3499 surfer, surfer_token = generate_user() 

3500 host, _ = generate_user() 

3501 

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

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

3504 with requests_session(surfer_token) as api: 

3505 hr_id = api.CreateHostRequest( 

3506 requests_pb2.CreateHostRequestReq( 

3507 host_user_id=host.id, 

3508 from_date=today_plus_2, 

3509 to_date=today_plus_3, 

3510 text=valid_request_text(), 

3511 ) 

3512 ).host_request_id 

3513 

3514 with real_moderation_session(super_token) as api: 

3515 res = api.SetUserContentVisibility( 

3516 moderation_pb2.SetUserContentVisibilityReq( 

3517 user_id=surfer.id, 

3518 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3519 from_visibility=[], 

3520 ) 

3521 ) 

3522 assert res.updated_count == 1 

3523 

3524 with session_scope() as session: 

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

3526 assert state.visibility == ModerationVisibility.hidden 

3527 

3528 

3529def test_SetUserContentVisibility_from_visibility_unspecified_rejected(db): 

3530 super_user, super_token = generate_user(is_superuser=True) 

3531 target, _ = generate_user() 

3532 

3533 with real_moderation_session(super_token) as api: 

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

3535 api.SetUserContentVisibility( 

3536 moderation_pb2.SetUserContentVisibilityReq( 

3537 user_id=target.id, 

3538 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3539 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED], 

3540 ) 

3541 ) 

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

3543 

3544 

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

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

3547 return [ 

3548 s.moderation_state_id 

3549 for s in res.moderation_states 

3550 if s.object_type != moderation_pb2.MODERATION_OBJECT_TYPE_USER 

3551 ] 

3552 

3553 

3554def test_ListModerationStates_with_no_content(db): 

3555 super_user, super_token = generate_user(is_superuser=True) 

3556 

3557 with real_moderation_session(super_token) as api: 

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

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

3560 assert _content_state_ids(res) == [] 

3561 assert res.next_page_token == "" 

3562 

3563 

3564def test_ListModerationStates_returns_states_chronologically(db): 

3565 super_user, super_token = generate_user(is_superuser=True) 

3566 surfer, surfer_token = generate_user() 

3567 host, _ = generate_user() 

3568 

3569 state1_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3570 state2_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3571 state3_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3572 

3573 with real_moderation_session(super_token) as api: 

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

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

3576 

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

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

3579 

3580 

3581def test_ListModerationStates_filter_by_author(db): 

3582 super_user, super_token = generate_user(is_superuser=True) 

3583 surfer1, surfer1_token = generate_user() 

3584 surfer2, surfer2_token = generate_user() 

3585 host, _ = generate_user() 

3586 

3587 state1_id = create_test_host_request_with_moderation(surfer1_token, host.id) 

3588 state2_id = create_test_host_request_with_moderation(surfer2_token, host.id) 

3589 state3_id = create_test_host_request_with_moderation(surfer1_token, host.id) 

3590 

3591 with real_moderation_session(super_token) as api: 

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

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

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

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

3596 

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

3598 assert _content_state_ids(res) == [state2_id] 

3599 

3600 

3601def test_ListModerationStates_pagination(db): 

3602 super_user, super_token = generate_user(is_superuser=True) 

3603 surfer, surfer_token = generate_user() 

3604 host, _ = generate_user() 

3605 

3606 for _ in range(3): 

3607 create_test_host_request_with_moderation(surfer_token, host.id) 

3608 

3609 with real_moderation_session(super_token) as api: 

3610 # 3 users plus 3 host requests 

3611 all_ids = [ 

3612 s.moderation_state_id 

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

3614 ] 

3615 assert len(all_ids) == 6 

3616 

3617 paged_ids = [] 

3618 page_token = "" 

3619 while True: 

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

3621 assert len(res.moderation_states) == 2 

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

3623 page_token = res.next_page_token 

3624 if not page_token: 

3625 break 

3626 

3627 assert paged_ids == all_ids 

3628 

3629 

3630def test_ListModerationStates_pagination_ignores_created(db): 

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

3632 super_user, super_token = generate_user(is_superuser=True) 

3633 surfer, surfer_token = generate_user() 

3634 host, _ = generate_user() 

3635 

3636 first_state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3637 create_test_host_request_with_moderation(surfer_token, host.id) 

3638 create_test_host_request_with_moderation(surfer_token, host.id) 

3639 

3640 with session_scope() as session: 

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

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

3643 

3644 paged_ids = [] 

3645 page_token = "" 

3646 with real_moderation_session(super_token) as api: 

3647 while True: 

3648 res = api.ListModerationStates( 

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

3650 ) 

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

3652 page_token = res.next_page_token 

3653 if not page_token: 

3654 break 

3655 

3656 with session_scope() as session: 

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

3658 

3659 assert paged_ids == expected 

3660 

3661 

3662# ============================================================================ 

3663# Tests for user account moderation states 

3664# ============================================================================ 

3665 

3666 

3667def test_user_moderation_state_created_with_the_account(db): 

3668 user, _ = generate_user() 

3669 

3670 with session_scope() as session: 

3671 state = session.execute( 

3672 select(ModerationState) 

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

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

3675 ).scalar_one() 

3676 

3677 assert state.visibility is None 

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

3679 

3680 log_entries = ( 

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

3682 ) 

3683 assert len(log_entries) == 1 

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

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

3686 

3687 queue_items = ( 

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

3689 .scalars() 

3690 .all() 

3691 ) 

3692 assert queue_items == [] 

3693 

3694 

3695def test_GetModerationState_for_user(db): 

3696 super_user, super_token = generate_user(is_superuser=True) 

3697 user, _ = generate_user() 

3698 

3699 with real_moderation_session(super_token) as api: 

3700 state = api.GetModerationState( 

3701 moderation_pb2.GetModerationStateReq( 

3702 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3703 object_id=user.id, 

3704 ) 

3705 ).moderation_state 

3706 assert state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_USER 

3707 assert state.object_id == user.id 

3708 assert state.author_user_id == user.id 

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

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

3711 assert state.visibility == moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED 

3712 

3713 

3714def test_GetModerationState_for_nonexistent_user(db): 

3715 super_user, super_token = generate_user(is_superuser=True) 

3716 

3717 with real_moderation_session(super_token) as api: 

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

3719 api.GetModerationState( 

3720 moderation_pb2.GetModerationStateReq( 

3721 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3722 object_id=999999, 

3723 ) 

3724 ) 

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

3726 

3727 

3728def test_flag_user_shows_up_in_queue_and_author_filters(db): 

3729 super_user, super_token = generate_user(is_superuser=True) 

3730 surfer, surfer_token = generate_user() 

3731 host, _ = generate_user() 

3732 other, _ = generate_user() 

3733 

3734 content_state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3735 

3736 with real_moderation_session(super_token) as api: 

3737 user_state_id = api.GetModerationState( 

3738 moderation_pb2.GetModerationStateReq( 

3739 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3740 object_id=surfer.id, 

3741 ) 

3742 ).moderation_state.moderation_state_id 

3743 

3744 api.ModerateContent( 

3745 moderation_pb2.ModerateContentReq( 

3746 moderation_state_id=user_state_id, 

3747 action=moderation_pb2.MODERATION_ACTION_FLAG, 

3748 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

3749 reason="Reported for spamming hosts", 

3750 ) 

3751 ) 

3752 

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

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

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

3756 assert user_item.trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG 

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

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

3759 

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

3761 assert len(res.queue_items) == 0 

3762 

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

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

3765 

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

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

3768 

3769 

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

3771def test_ModerateContent_cannot_set_visibility_on_user_state(db, action): 

3772 super_user, super_token = generate_user(is_superuser=True) 

3773 user, _ = generate_user() 

3774 

3775 with real_moderation_session(super_token) as api: 

3776 user_state_id = api.GetModerationState( 

3777 moderation_pb2.GetModerationStateReq( 

3778 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3779 object_id=user.id, 

3780 ) 

3781 ).moderation_state.moderation_state_id 

3782 

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

3784 api.ModerateContent( 

3785 moderation_pb2.ModerateContentReq( 

3786 moderation_state_id=user_state_id, 

3787 action=action, 

3788 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3789 ) 

3790 ) 

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

3792 

3793 with session_scope() as session: 

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

3795 assert state.visibility is None 

3796 

3797 

3798def test_SetUserContentVisibility_leaves_user_state_alone(db): 

3799 super_user, super_token = generate_user(is_superuser=True) 

3800 surfer, surfer_token = generate_user() 

3801 host, _ = generate_user() 

3802 

3803 content_state_id = create_test_host_request_with_moderation(surfer_token, host.id) 

3804 

3805 with real_moderation_session(super_token) as api: 

3806 user_state_id = api.GetModerationState( 

3807 moderation_pb2.GetModerationStateReq( 

3808 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER, 

3809 object_id=surfer.id, 

3810 ) 

3811 ).moderation_state.moderation_state_id 

3812 

3813 res = api.SetUserContentVisibility( 

3814 moderation_pb2.SetUserContentVisibilityReq( 

3815 user_id=surfer.id, 

3816 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

3817 ) 

3818 ) 

3819 assert res.updated_count == 1 

3820 

3821 with session_scope() as session: 

3822 content_state = session.execute( 

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

3824 ).scalar_one() 

3825 assert content_state.visibility == ModerationVisibility.hidden 

3826 

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

3828 assert user_state.visibility is None 

3829 

3830 

3831# ============================================================================ 

3832# Tests for the moderation model metadata 

3833# ============================================================================ 

3834 

3835 

3836def test_every_object_type_has_a_model(): 

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

3838 assert set(get_moderated_models()) == set(ModerationObjectType) 

3839 

3840 

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

3842def test_visibility_check_constraint_matches_the_models(db, object_type): 

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

3844 has_own_visibility_mechanism = get_moderated_models()[object_type].has_own_visibility_mechanism 

3845 

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

3847 with session_scope() as session: 

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

3849 

3850 if has_own_visibility_mechanism: 

3851 add_state(1, None) 

3852 with pytest.raises(IntegrityError): 

3853 add_state(2, ModerationVisibility.visible) 

3854 else: 

3855 add_state(1, ModerationVisibility.visible) 

3856 with pytest.raises(IntegrityError): 

3857 add_state(2, None)