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

1180 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 09:44 +0000

1""" 

2Comprehensive tests for the Unified Moderation System (UMS) 

3""" 

4 

5from datetime import datetime, timedelta 

6 

7import grpc 

8import pytest 

9from google.protobuf import empty_pb2 

10from sqlalchemy.sql import select 

11 

12from couchers.config import config 

13from couchers.db import session_scope 

14from couchers.jobs.handlers import auto_approve_moderation_queue 

15from couchers.jobs.worker import process_job 

16from couchers.models import ( 

17 AdminAction, 

18 EventOccurrence, 

19 FriendRelationship, 

20 GroupChat, 

21 HostRequest, 

22 ModerationAction, 

23 ModerationLog, 

24 ModerationObjectType, 

25 ModerationQueueItem, 

26 ModerationState, 

27 ModerationTrigger, 

28 ModerationVisibility, 

29) 

30from couchers.moderation.utils import create_moderation 

31from couchers.proto import api_pb2, conversations_pb2, events_pb2, moderation_pb2, notifications_pb2, requests_pb2 

32from couchers.utils import Timestamp_from_datetime, now, today 

33from tests.fixtures.db import generate_user, make_friends 

34from tests.fixtures.misc import PushCollector, mock_notification_email, process_jobs 

35from tests.fixtures.sessions import ( 

36 api_session, 

37 conversations_session, 

38 events_session, 

39 notifications_session, 

40 real_moderation_session, 

41 requests_session, 

42) 

43from tests.test_communities import create_community 

44from tests.test_requests import valid_request_text 

45 

46 

47@pytest.fixture(autouse=True) 

48def _(testconfig): 

49 pass 

50 

51 

52def create_test_host_request_with_moderation(surfer_token, host_user_id): 

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

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

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

56 

57 with requests_session(surfer_token) as api: 

58 hr_id = api.CreateHostRequest( 

59 requests_pb2.CreateHostRequestReq( 

60 host_user_id=host_user_id, 

61 from_date=today_plus_2, 

62 to_date=today_plus_3, 

63 text=valid_request_text(), 

64 ) 

65 ).host_request_id 

66 

67 with session_scope() as session: 

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

69 return hr.moderation_state_id 

70 

71 

72# ============================================================================ 

73# Tests for moderation helper functions 

74# ============================================================================ 

75 

76 

77def test_create_moderation(db): 

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

79 user, _ = generate_user() 

80 

81 with session_scope() as session: 

82 # Create a moderation state 

83 moderation_state = create_moderation( 

84 session=session, 

85 object_type=ModerationObjectType.host_request, 

86 object_id=123, 

87 creator_user_id=user.id, 

88 ) 

89 

90 assert moderation_state.object_type == ModerationObjectType.host_request 

91 assert moderation_state.object_id == 123 

92 assert moderation_state.visibility == ModerationVisibility.shadowed 

93 

94 # Check that log entry was created 

95 log_entries = ( 

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

97 .scalars() 

98 .all() 

99 ) 

100 

101 assert len(log_entries) == 1 

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

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

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

105 

106 

107def test_add_to_moderation_queue(db): 

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

109 super_user, super_token = generate_user(is_superuser=True) 

110 user1, token1 = generate_user() 

111 user2, _ = generate_user() 

112 

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

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

115 

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

117 with requests_session(token1) as api: 

118 host_request_id = api.CreateHostRequest( 

119 requests_pb2.CreateHostRequestReq( 

120 host_user_id=user2.id, 

121 from_date=today_plus_2, 

122 to_date=today_plus_3, 

123 text=valid_request_text(), 

124 ) 

125 ).host_request_id 

126 

127 # Get the moderation state ID 

128 state_id = None 

129 with session_scope() as session: 

130 host_request = session.execute( 

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

132 ).scalar_one() 

133 state_id = host_request.moderation_state_id 

134 

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

136 with real_moderation_session(super_token) as api: 

137 res = api.FlagContentForReview( 

138 moderation_pb2.FlagContentForReviewReq( 

139 moderation_state_id=state_id, 

140 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

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

142 ) 

143 ) 

144 

145 assert res.queue_item.moderation_state_id == state_id 

146 assert res.queue_item.trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG 

147 assert res.queue_item.reason == "Admin manually flagged for additional review" 

148 assert res.queue_item.moderation_state.author_user_id == user1.id 

149 assert res.queue_item.is_resolved == False 

150 

151 

152def test_moderate_content(db): 

153 """Test moderating content via API""" 

154 super_user, super_token = generate_user(is_superuser=True) 

155 user, token = generate_user() 

156 host, _ = generate_user() 

157 

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

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

160 

161 # Create a real host request 

162 state_id = None 

163 with requests_session(token) as api: 

164 hr_id = api.CreateHostRequest( 

165 requests_pb2.CreateHostRequestReq( 

166 host_user_id=host.id, 

167 from_date=today_plus_2, 

168 to_date=today_plus_3, 

169 text=valid_request_text(), 

170 ) 

171 ).host_request_id 

172 

173 with session_scope() as session: 

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

175 state_id = hr.moderation_state_id 

176 

177 # Moderate the content via API 

178 with real_moderation_session(super_token) as api: 

179 res = api.ModerateContent( 

180 moderation_pb2.ModerateContentReq( 

181 moderation_state_id=state_id, 

182 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

183 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

184 reason="Content looks good", 

185 ) 

186 ) 

187 

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

189 

190 # Check that state was updated in database 

191 with session_scope() as session: 

192 updated_state = session.get_one(ModerationState, state_id) 

193 assert updated_state.visibility == ModerationVisibility.visible 

194 

195 # Check that log entry was created 

196 log_entries = ( 

197 session.execute( 

198 select(ModerationLog) 

199 .where(ModerationLog.moderation_state_id == state_id) 

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

201 ) 

202 .scalars() 

203 .all() 

204 ) 

205 

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

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

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

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

210 

211 

212def test_resolve_queue_item(db): 

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

214 user1, token1 = generate_user() 

215 user2, _ = generate_user() 

216 moderator, moderator_token = generate_user(is_superuser=True) 

217 

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

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

220 

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

222 with requests_session(token1) as api: 

223 host_request_id = api.CreateHostRequest( 

224 requests_pb2.CreateHostRequestReq( 

225 host_user_id=user2.id, 

226 from_date=today_plus_2, 

227 to_date=today_plus_3, 

228 text=valid_request_text(), 

229 ) 

230 ).host_request_id 

231 

232 state_id = None 

233 with session_scope() as session: 

234 # Get the host request and its moderation state 

235 host_request = session.execute( 

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

237 ).scalar_one() 

238 state_id = host_request.moderation_state_id 

239 

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

241 queue_item = session.execute( 

242 select(ModerationQueueItem) 

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

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

245 ).scalar_one() 

246 

247 assert queue_item.resolved_by_log_id is None 

248 

249 # Approve content via API (which should resolve the queue item) 

250 with real_moderation_session(moderator_token) as api: 

251 api.ModerateContent( 

252 moderation_pb2.ModerateContentReq( 

253 moderation_state_id=state_id, 

254 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

255 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

256 reason="Approved after review", 

257 ) 

258 ) 

259 

260 # Check that queue item was resolved 

261 with session_scope() as session: 

262 queue_item = session.execute( 

263 select(ModerationQueueItem) 

264 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

266 ).scalar_one() 

267 assert queue_item.resolved_by_log_id is not None 

268 

269 

270def test_approve_content_via_api(db): 

271 """Test approving content via ModerateContent API""" 

272 user1, token1 = generate_user() 

273 user2, _ = generate_user() 

274 moderator, moderator_token = generate_user(is_superuser=True) 

275 

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

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

278 

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

280 with requests_session(token1) as api: 

281 host_request_id = api.CreateHostRequest( 

282 requests_pb2.CreateHostRequestReq( 

283 host_user_id=user2.id, 

284 from_date=today_plus_2, 

285 to_date=today_plus_3, 

286 text=valid_request_text(), 

287 ) 

288 ).host_request_id 

289 

290 state_id = None 

291 with session_scope() as session: 

292 # Get the host request and its moderation state 

293 host_request = session.execute( 

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

295 ).scalar_one() 

296 state_id = host_request.moderation_state_id 

297 

298 # Approve via API 

299 with real_moderation_session(moderator_token) as api: 

300 api.ModerateContent( 

301 moderation_pb2.ModerateContentReq( 

302 moderation_state_id=state_id, 

303 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

304 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

305 reason="Quick approval", 

306 ) 

307 ) 

308 

309 # Check that state was updated to VISIBLE 

310 with session_scope() as session: 

311 updated_state = session.get_one(ModerationState, state_id) 

312 assert updated_state.visibility == ModerationVisibility.visible 

313 

314 # Check log entry 

315 log_entry = session.execute( 

316 select(ModerationLog) 

317 .where(ModerationLog.moderation_state_id == state_id) 

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

319 ).scalar_one() 

320 

321 assert log_entry.moderator_user_id == moderator.id 

322 assert log_entry.reason == "Quick approval" 

323 

324 

325# ============================================================================ 

326# Tests for host request moderation integration 

327# ============================================================================ 

328 

329 

330def test_create_host_request_creates_moderation_state(db): 

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

332 user1, token1 = generate_user() 

333 user2, token2 = generate_user() 

334 

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

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

337 

338 with requests_session(token1) as api: 

339 host_request_id = api.CreateHostRequest( 

340 requests_pb2.CreateHostRequestReq( 

341 host_user_id=user2.id, 

342 from_date=today_plus_2, 

343 to_date=today_plus_3, 

344 text=valid_request_text(), 

345 ) 

346 ).host_request_id 

347 

348 with session_scope() as session: 

349 # Check that host request has a moderation state 

350 host_request = session.execute( 

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

352 ).scalar_one() 

353 

354 # Check moderation state properties 

355 moderation_state = session.execute( 

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

357 ).scalar_one() 

358 

359 assert moderation_state.object_type == ModerationObjectType.host_request 

360 assert moderation_state.object_id == host_request_id 

361 assert moderation_state.visibility == ModerationVisibility.shadowed 

362 

363 # Check that it was added to moderation queue 

364 queue_items = ( 

365 session.execute( 

366 select(ModerationQueueItem) 

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

368 .where(ModerationQueueItem.resolved_by_log_id == None) 

369 ) 

370 .scalars() 

371 .all() 

372 ) 

373 

374 assert len(queue_items) == 1 

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

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

377 

378 

379def test_host_request_no_notification_before_approval(db, push_collector: PushCollector): 

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

381 user1, token1 = generate_user() 

382 user2, token2 = generate_user() 

383 

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

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

386 

387 with requests_session(token1) as api: 

388 host_request_id = api.CreateHostRequest( 

389 requests_pb2.CreateHostRequestReq( 

390 host_user_id=user2.id, 

391 from_date=today_plus_2, 

392 to_date=today_plus_3, 

393 text=valid_request_text(), 

394 ) 

395 ).host_request_id 

396 

397 # Process all jobs (including the notification job) 

398 process_jobs() 

399 

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

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

402 

403 

404def test_shadowed_notification_not_in_list_notifications(db): 

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

406 user1, token1 = generate_user() 

407 user2, token2 = generate_user() 

408 

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

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

411 

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

413 with requests_session(token1) as api: 

414 host_request_id = api.CreateHostRequest( 

415 requests_pb2.CreateHostRequestReq( 

416 host_user_id=user2.id, 

417 from_date=today_plus_2, 

418 to_date=today_plus_3, 

419 text=valid_request_text(), 

420 ) 

421 ).host_request_id 

422 

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

424 with notifications_session(token2) as api: 

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

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

427 assert len(res.notifications) == 0 

428 

429 

430def test_notification_visible_after_approval(db): 

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

432 user1, token1 = generate_user() 

433 user2, token2 = generate_user() 

434 mod, mod_token = generate_user(is_superuser=True) 

435 

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

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

438 

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

440 with requests_session(token1) as api: 

441 host_request_id = api.CreateHostRequest( 

442 requests_pb2.CreateHostRequestReq( 

443 host_user_id=user2.id, 

444 from_date=today_plus_2, 

445 to_date=today_plus_3, 

446 text=valid_request_text(), 

447 ) 

448 ).host_request_id 

449 

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

451 with notifications_session(token2) as api: 

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

453 assert len(res.notifications) == 0 

454 

455 # Get the moderation state ID and approve 

456 with session_scope() as session: 

457 host_request = session.execute( 

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

459 ).scalar_one() 

460 state_id = host_request.moderation_state_id 

461 

462 with real_moderation_session(mod_token) as api: 

463 api.ModerateContent( 

464 moderation_pb2.ModerateContentReq( 

465 moderation_state_id=state_id, 

466 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

467 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

468 reason="Looks good", 

469 ) 

470 ) 

471 

472 # Now host SHOULD see the notification 

473 with notifications_session(token2) as api: 

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

475 assert len(res.notifications) == 1 

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

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

478 

479 

480def test_shadowed_host_request_visible_to_author_only(db): 

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

482 user1, token1 = generate_user() 

483 user2, token2 = generate_user() 

484 

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

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

487 

488 with requests_session(token1) as api: 

489 host_request_id = api.CreateHostRequest( 

490 requests_pb2.CreateHostRequestReq( 

491 host_user_id=user2.id, 

492 from_date=today_plus_2, 

493 to_date=today_plus_3, 

494 text=valid_request_text(), 

495 ) 

496 ).host_request_id 

497 

498 # Surfer (author) can see it with GetHostRequest 

499 with requests_session(token1) as api: 

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

501 assert res.host_request_id == host_request_id 

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

503 

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

505 with requests_session(token2) as api: 

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

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

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

509 

510 

511def test_unlisted_host_request_not_in_lists(db): 

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

513 user1, token1 = generate_user() 

514 user2, token2 = generate_user() 

515 

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

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

518 

519 with requests_session(token1) as api: 

520 host_request_id = api.CreateHostRequest( 

521 requests_pb2.CreateHostRequestReq( 

522 host_user_id=user2.id, 

523 from_date=today_plus_2, 

524 to_date=today_plus_3, 

525 text=valid_request_text(), 

526 ) 

527 ).host_request_id 

528 

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

530 with requests_session(token1) as api: 

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

532 assert len(res.host_requests) == 1 

533 

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

535 with requests_session(token2) as api: 

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

537 assert len(res.host_requests) == 0 

538 

539 

540def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector): 

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

542 user1, token1 = generate_user() 

543 user2, token2 = generate_user() 

544 mod, mod_token = generate_user(is_superuser=True) 

545 

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

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

548 

549 with requests_session(token1) as api: 

550 host_request_id = api.CreateHostRequest( 

551 requests_pb2.CreateHostRequestReq( 

552 host_user_id=user2.id, 

553 from_date=today_plus_2, 

554 to_date=today_plus_3, 

555 text=valid_request_text(), 

556 ) 

557 ).host_request_id 

558 

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

560 process_jobs() 

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

562 

563 # Get the moderation state ID 

564 state_id = None 

565 with session_scope() as session: 

566 host_request = session.execute( 

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

568 ).scalar_one() 

569 state_id = host_request.moderation_state_id 

570 

571 # Approve the host request via API 

572 with real_moderation_session(mod_token) as api: 

573 api.ModerateContent( 

574 moderation_pb2.ModerateContentReq( 

575 moderation_state_id=state_id, 

576 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

577 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

578 reason="Looks good", 

579 ) 

580 ) 

581 

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

583 process_jobs() 

584 

585 # Now surfer SHOULD see it in their sent list 

586 with requests_session(token1) as api: 

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

588 assert len(res.host_requests) == 1 

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

590 

591 # Host SHOULD see it in their received list 

592 with requests_session(token2) as api: 

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

594 assert len(res.host_requests) == 1 

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

596 

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

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

599 

600 

601def test_hidden_host_request_invisible_to_all(db): 

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

603 user1, token1 = generate_user() 

604 user2, token2 = generate_user() 

605 user3, token3 = generate_user() # Third party 

606 moderator, moderator_token = generate_user(is_superuser=True) 

607 

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

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

610 

611 with requests_session(token1) as api: 

612 host_request_id = api.CreateHostRequest( 

613 requests_pb2.CreateHostRequestReq( 

614 host_user_id=user2.id, 

615 from_date=today_plus_2, 

616 to_date=today_plus_3, 

617 text=valid_request_text(), 

618 ) 

619 ).host_request_id 

620 

621 # Get the moderation state ID 

622 state_id = None 

623 with session_scope() as session: 

624 host_request = session.execute( 

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

626 ).scalar_one() 

627 state_id = host_request.moderation_state_id 

628 

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

630 with real_moderation_session(moderator_token) as api: 

631 api.ModerateContent( 

632 moderation_pb2.ModerateContentReq( 

633 moderation_state_id=state_id, 

634 action=moderation_pb2.MODERATION_ACTION_HIDE, 

635 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

636 reason="Spam content", 

637 ) 

638 ) 

639 

640 # Surfer can't see it with GetHostRequest 

641 with requests_session(token1) as api: 

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

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

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

645 

646 # Host can't see it with GetHostRequest 

647 with requests_session(token2) as api: 

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

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

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

651 

652 # Third party definitely can't see it 

653 with requests_session(token3) as api: 

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

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

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

657 

658 # Not in any lists 

659 with requests_session(token1) as api: 

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

661 assert len(res.host_requests) == 0 

662 

663 with requests_session(token2) as api: 

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

665 assert len(res.host_requests) == 0 

666 

667 

668def test_multiple_host_requests_listing_visibility(db): 

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

670 user1, token1 = generate_user() 

671 user2, token2 = generate_user() 

672 moderator, moderator_token = generate_user(is_superuser=True) 

673 

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

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

676 

677 # Create 3 host requests 

678 host_request_ids = [] 

679 state_ids = [] 

680 with requests_session(token1) as api: 

681 for i in range(3): 

682 hr_id = api.CreateHostRequest( 

683 requests_pb2.CreateHostRequestReq( 

684 host_user_id=user2.id, 

685 from_date=today_plus_2, 

686 to_date=today_plus_3, 

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

688 ) 

689 ).host_request_id 

690 host_request_ids.append(hr_id) 

691 

692 # Get state IDs 

693 with session_scope() as session: 

694 for hr_id in host_request_ids: 

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

696 state_ids.append(host_request.moderation_state_id) 

697 

698 # Approve the first one via API 

699 with real_moderation_session(moderator_token) as api: 

700 api.ModerateContent( 

701 moderation_pb2.ModerateContentReq( 

702 moderation_state_id=state_ids[0], 

703 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

704 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

705 reason="Approved", 

706 ) 

707 ) 

708 

709 # Hide the third one via API 

710 with real_moderation_session(moderator_token) as api: 

711 api.ModerateContent( 

712 moderation_pb2.ModerateContentReq( 

713 moderation_state_id=state_ids[2], 

714 action=moderation_pb2.MODERATION_ACTION_HIDE, 

715 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

716 reason="Spam", 

717 ) 

718 ) 

719 

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

721 with requests_session(token1) as api: 

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

723 assert len(res.host_requests) == 2 

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

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

726 

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

728 with requests_session(token2) as api: 

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

730 assert len(res.host_requests) == 1 

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

732 

733 

734def test_moderation_log_tracking(db): 

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

736 user, user_token = generate_user() 

737 host, _ = generate_user() 

738 moderator1, moderator1_token = generate_user(is_superuser=True) 

739 moderator2, moderator2_token = generate_user(is_superuser=True) 

740 

741 # Create a real host request 

742 state_id = create_test_host_request_with_moderation(user_token, host.id) 

743 

744 # Perform several moderation actions via API 

745 with real_moderation_session(moderator1_token) as api: 

746 api.ModerateContent( 

747 moderation_pb2.ModerateContentReq( 

748 moderation_state_id=state_id, 

749 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

750 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

751 reason="Looks good initially", 

752 ) 

753 ) 

754 

755 with real_moderation_session(moderator2_token) as api: 

756 api.FlagContentForReview( 

757 moderation_pb2.FlagContentForReviewReq( 

758 moderation_state_id=state_id, 

759 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

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

761 ) 

762 ) 

763 # Shadow it back 

764 api.ModerateContent( 

765 moderation_pb2.ModerateContentReq( 

766 moderation_state_id=state_id, 

767 action=moderation_pb2.MODERATION_ACTION_HIDE, 

768 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

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

770 ) 

771 ) 

772 

773 with real_moderation_session(moderator1_token) as api: 

774 api.ModerateContent( 

775 moderation_pb2.ModerateContentReq( 

776 moderation_state_id=state_id, 

777 action=moderation_pb2.MODERATION_ACTION_HIDE, 

778 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

779 reason="Actually it's spam", 

780 ) 

781 ) 

782 

783 # Check all log entries 

784 with session_scope() as session: 

785 log_entries = ( 

786 session.execute( 

787 select(ModerationLog) 

788 .where(ModerationLog.moderation_state_id == state_id) 

789 .order_by(ModerationLog.time.asc()) 

790 ) 

791 .scalars() 

792 .all() 

793 ) 

794 

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

796 assert len(log_entries) >= 3 

797 

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

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

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

801 

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

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

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

805 

806 # The last action should be hiding 

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

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

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

810 

811 

812def test_moderation_queue_workflow(db): 

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

814 user1, token1 = generate_user() 

815 user2, _ = generate_user() 

816 moderator, moderator_token = generate_user(is_superuser=True) 

817 

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

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

820 

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

822 with requests_session(token1) as api: 

823 host_request_id = api.CreateHostRequest( 

824 requests_pb2.CreateHostRequestReq( 

825 host_user_id=user2.id, 

826 from_date=today_plus_2, 

827 to_date=today_plus_3, 

828 text=valid_request_text(), 

829 ) 

830 ).host_request_id 

831 

832 state_id = None 

833 queue_item_id = None 

834 with session_scope() as session: 

835 # Get the host request and its moderation state 

836 host_request = session.execute( 

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

838 ).scalar_one() 

839 state_id = host_request.moderation_state_id 

840 

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

842 queue_item = session.execute( 

843 select(ModerationQueueItem) 

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

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

846 ).scalar_one() 

847 queue_item_id = queue_item.id 

848 

849 # Verify it's in the queue 

850 unresolved_items = ( 

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

852 .scalars() 

853 .all() 

854 ) 

855 

856 assert len(unresolved_items) >= 1 

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

858 

859 # Moderator reviews and approves via API (which also resolves the queue item) 

860 with real_moderation_session(moderator_token) as api: 

861 api.ModerateContent( 

862 moderation_pb2.ModerateContentReq( 

863 moderation_state_id=state_id, 

864 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

865 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

866 reason="Content approved", 

867 ) 

868 ) 

869 

870 # Verify queue item was resolved 

871 with session_scope() as session: 

872 # Verify it's no longer in unresolved queue 

873 unresolved_items = ( 

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

875 .scalars() 

876 .all() 

877 ) 

878 

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

880 

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

882 queue_item = session.get_one(ModerationQueueItem, queue_item_id) 

883 assert queue_item.resolved_by_log_id is not None 

884 

885 

886# ============================================================================ 

887# Moderation API Tests (testing the gRPC servicer) 

888# ============================================================================ 

889 

890 

891def test_GetModerationQueue_empty(db): 

892 """Test getting an empty moderation queue""" 

893 super_user, super_token = generate_user(is_superuser=True) 

894 

895 with real_moderation_session(super_token) as api: 

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

897 assert len(res.queue_items) == 0 

898 assert res.next_page_token == "" 

899 

900 

901def test_GetModerationQueue_with_items(db): 

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

903 super_user, super_token = generate_user(is_superuser=True) 

904 normal_user, user_token = generate_user() 

905 host, _ = generate_user() 

906 

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

908 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

909 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

910 

911 with real_moderation_session(super_token) as api: 

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

913 assert len(res.queue_items) == 2 

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

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

916 

917 

918def test_GetModerationQueue_filter_by_trigger(db): 

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

920 super_user, super_token = generate_user(is_superuser=True) 

921 normal_user, user_token = generate_user() 

922 host, _ = generate_user() 

923 

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

925 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

926 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

927 

928 # Add USER_FLAG trigger to second item via API 

929 with real_moderation_session(super_token) as api: 

930 api.FlagContentForReview( 

931 moderation_pb2.FlagContentForReviewReq( 

932 moderation_state_id=state2_id, 

933 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

934 reason="Reported by user", 

935 ) 

936 ) 

937 

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

939 with real_moderation_session(super_token) as api: 

940 res = api.GetModerationQueue( 

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

942 ) 

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

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

945 

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

947 with real_moderation_session(super_token) as api: 

948 res = api.GetModerationQueue( 

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

950 ) 

951 assert len(res.queue_items) == 1 

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

953 

954 

955def test_GetModerationQueue_filter_created_before(db): 

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

957 super_user, super_token = generate_user(is_superuser=True) 

958 normal_user, user_token = generate_user() 

959 host, _ = generate_user() 

960 

961 # Create host requests 

962 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

963 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

964 

965 # Backdate the first queue item 

966 with session_scope() as session: 

967 queue_item1 = session.execute( 

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

969 ).scalar_one() 

970 # Set it to 2 hours ago 

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

972 

973 # The second item remains at current time 

974 

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

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

977 with real_moderation_session(super_token) as api: 

978 res = api.GetModerationQueue( 

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

980 ) 

981 assert len(res.queue_items) == 1 

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

983 

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

985 with real_moderation_session(super_token) as api: 

986 res = api.GetModerationQueue( 

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

988 ) 

989 assert len(res.queue_items) == 2 

990 

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

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

993 with real_moderation_session(super_token) as api: 

994 res = api.GetModerationQueue( 

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

996 ) 

997 assert len(res.queue_items) == 0 

998 

999 

1000def test_GetModerationQueue_filter_created_after(db): 

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

1002 super_user, super_token = generate_user(is_superuser=True) 

1003 normal_user, user_token = generate_user() 

1004 host, _ = generate_user() 

1005 

1006 # Create host requests 

1007 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1008 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1009 

1010 # Backdate the first queue item to 2 hours ago 

1011 with session_scope() as session: 

1012 queue_item1 = session.execute( 

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

1014 ).scalar_one() 

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

1016 

1017 # The second item remains at current time 

1018 

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

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

1021 with real_moderation_session(super_token) as api: 

1022 res = api.GetModerationQueue( 

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

1024 ) 

1025 assert len(res.queue_items) == 1 

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

1027 

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

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

1030 with real_moderation_session(super_token) as api: 

1031 res = api.GetModerationQueue( 

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

1033 ) 

1034 assert len(res.queue_items) == 2 

1035 

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

1037 with real_moderation_session(super_token) as api: 

1038 res = api.GetModerationQueue( 

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

1040 ) 

1041 assert len(res.queue_items) == 0 

1042 

1043 

1044def test_GetModerationQueue_filter_created_before_and_after(db): 

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

1046 super_user, super_token = generate_user(is_superuser=True) 

1047 normal_user, user_token = generate_user() 

1048 host, _ = generate_user() 

1049 

1050 # Create 3 host requests 

1051 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1052 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1053 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1054 

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

1056 with session_scope() as session: 

1057 queue_item1 = session.execute( 

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

1059 ).scalar_one() 

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

1061 

1062 queue_item2 = session.execute( 

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

1064 ).scalar_one() 

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

1066 

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

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

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

1070 with real_moderation_session(super_token) as api: 

1071 res = api.GetModerationQueue( 

1072 moderation_pb2.GetModerationQueueReq( 

1073 created_after=Timestamp_from_datetime(after_cutoff), 

1074 created_before=Timestamp_from_datetime(before_cutoff), 

1075 ) 

1076 ) 

1077 assert len(res.queue_items) == 1 

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

1079 

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

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

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

1083 with real_moderation_session(super_token) as api: 

1084 res = api.GetModerationQueue( 

1085 moderation_pb2.GetModerationQueueReq( 

1086 created_after=Timestamp_from_datetime(after_cutoff), 

1087 created_before=Timestamp_from_datetime(before_cutoff), 

1088 ) 

1089 ) 

1090 assert len(res.queue_items) == 1 

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

1092 

1093 

1094def test_GetModerationQueue_filter_unresolved(db): 

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

1096 super_user, super_token = generate_user(is_superuser=True) 

1097 normal_user, user_token = generate_user() 

1098 host, _ = generate_user() 

1099 

1100 # Create 2 host requests 

1101 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1102 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1103 

1104 # Resolve the first one via API (ModerateContent automatically resolves queue items) 

1105 with real_moderation_session(super_token) as api: 

1106 api.ModerateContent( 

1107 moderation_pb2.ModerateContentReq( 

1108 moderation_state_id=state1_id, 

1109 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1110 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1111 reason="Approved", 

1112 ) 

1113 ) 

1114 

1115 # Get all items 

1116 with real_moderation_session(super_token) as api: 

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

1118 assert len(res.queue_items) == 2 

1119 

1120 # Get only unresolved items 

1121 with real_moderation_session(super_token) as api: 

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

1123 assert len(res.queue_items) == 1 

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

1125 

1126 

1127def test_GetModerationQueue_filter_by_author(db): 

1128 """Test filtering moderation queue by item_author_user_id""" 

1129 super_user, super_token = generate_user(is_superuser=True) 

1130 user1, token1 = generate_user() 

1131 user2, token2 = generate_user() 

1132 host_user, _ = generate_user() 

1133 

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

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

1136 

1137 # Create 2 host requests by user1 

1138 with requests_session(token1) as api: 

1139 hr1_id = api.CreateHostRequest( 

1140 requests_pb2.CreateHostRequestReq( 

1141 host_user_id=host_user.id, 

1142 from_date=today_plus_2, 

1143 to_date=today_plus_3, 

1144 text=valid_request_text(), 

1145 ) 

1146 ).host_request_id 

1147 

1148 hr2_id = api.CreateHostRequest( 

1149 requests_pb2.CreateHostRequestReq( 

1150 host_user_id=host_user.id, 

1151 from_date=today_plus_2, 

1152 to_date=today_plus_3, 

1153 text=valid_request_text(), 

1154 ) 

1155 ).host_request_id 

1156 

1157 # Create 1 host request by user2 

1158 with requests_session(token2) as api: 

1159 hr3_id = api.CreateHostRequest( 

1160 requests_pb2.CreateHostRequestReq( 

1161 host_user_id=host_user.id, 

1162 from_date=today_plus_2, 

1163 to_date=today_plus_3, 

1164 text=valid_request_text(), 

1165 ) 

1166 ).host_request_id 

1167 

1168 # Get moderation state IDs 

1169 state1_id, state2_id, state3_id = None, None, None 

1170 with session_scope() as session: 

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

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

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

1174 state1_id = hr1.moderation_state_id 

1175 state2_id = hr2.moderation_state_id 

1176 state3_id = hr3.moderation_state_id 

1177 

1178 # Get all items (should be 3) 

1179 with real_moderation_session(super_token) as api: 

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

1181 assert len(res.queue_items) == 3 

1182 

1183 # Filter by user1 (should get 2) 

1184 with real_moderation_session(super_token) as api: 

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

1186 assert len(res.queue_items) == 2 

1187 assert all(item.moderation_state.author_user_id == user1.id for item in res.queue_items) 

1188 

1189 # Filter by user2 (should get 1) 

1190 with real_moderation_session(super_token) as api: 

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

1192 assert len(res.queue_items) == 1 

1193 assert res.queue_items[0].moderation_state.author_user_id == user2.id 

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

1195 

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

1197 with real_moderation_session(super_token) as api: 

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

1199 assert len(res.queue_items) == 0 

1200 

1201 

1202def test_GetModerationQueue_ordering(db): 

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

1204 super_user, super_token = generate_user(is_superuser=True) 

1205 normal_user, user_token = generate_user() 

1206 host, _ = generate_user() 

1207 

1208 # Create 3 host requests 

1209 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1210 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1211 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1212 

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

1214 with session_scope() as session: 

1215 queue_item1 = session.execute( 

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

1217 ).scalar_one() 

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

1219 

1220 queue_item2 = session.execute( 

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

1222 ).scalar_one() 

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

1224 

1225 queue_item3 = session.execute( 

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

1227 ).scalar_one() 

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

1229 

1230 # Default order (oldest first) 

1231 with real_moderation_session(super_token) as api: 

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

1233 assert len(res.queue_items) == 3 

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

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

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

1237 

1238 # Explicit oldest first 

1239 with real_moderation_session(super_token) as api: 

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

1241 assert len(res.queue_items) == 3 

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

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

1244 

1245 # Newest first 

1246 with real_moderation_session(super_token) as api: 

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

1248 assert len(res.queue_items) == 3 

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

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

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

1252 

1253 

1254def test_GetModerationQueue_pagination_newest_first(db): 

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

1256 super_user, super_token = generate_user(is_superuser=True) 

1257 normal_user, normal_token = generate_user() 

1258 host_user, _ = generate_user() 

1259 

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

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

1262 

1263 # Create 5 host requests 

1264 hr_ids = [] 

1265 with requests_session(normal_token) as api: 

1266 for i in range(5): 

1267 hr_id = api.CreateHostRequest( 

1268 requests_pb2.CreateHostRequestReq( 

1269 host_user_id=host_user.id, 

1270 from_date=today_plus_2, 

1271 to_date=today_plus_3, 

1272 text=valid_request_text(), 

1273 ) 

1274 ).host_request_id 

1275 hr_ids.append(hr_id) 

1276 

1277 # Get moderation state IDs 

1278 state_ids = [] 

1279 with session_scope() as session: 

1280 for hr_id in hr_ids: 

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

1282 state_ids.append(hr.moderation_state_id) 

1283 

1284 # Set different times so ordering is deterministic 

1285 with session_scope() as session: 

1286 for i, state_id in enumerate(state_ids): 

1287 queue_item = session.execute( 

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

1289 ).scalar_one() 

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

1291 

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

1293 with real_moderation_session(super_token) as api: 

1294 res1 = api.GetModerationQueue( 

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

1296 ) 

1297 assert len(res1.queue_items) == 2 

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

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

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

1301 assert res1.next_page_token # should have more pages 

1302 

1303 # Get second page using the token 

1304 res2 = api.GetModerationQueue( 

1305 moderation_pb2.GetModerationQueueReq( 

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

1307 ) 

1308 ) 

1309 assert len(res2.queue_items) == 2 

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

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

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

1313 

1314 # Pages should not overlap 

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

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

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

1318 

1319 

1320def test_GetModerationLog(db): 

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

1322 super_user, super_token = generate_user(is_superuser=True) 

1323 moderator, moderator_token = generate_user(is_superuser=True) 

1324 normal_user, user_token = generate_user() 

1325 host, _ = generate_user() 

1326 

1327 # Create a real host request 

1328 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1329 

1330 # Perform a moderation action via API 

1331 with real_moderation_session(moderator_token) as api: 

1332 api.ModerateContent( 

1333 moderation_pb2.ModerateContentReq( 

1334 moderation_state_id=state_id, 

1335 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1336 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1337 reason="Looks good", 

1338 ) 

1339 ) 

1340 

1341 with real_moderation_session(super_token) as api: 

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

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

1344 assert res.moderation_state.moderation_state_id == state_id 

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

1346 # Log entries are in reverse chronological order 

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

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

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

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

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

1352 

1353 

1354def test_GetModerationLog_not_found(db): 

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

1356 super_user, super_token = generate_user(is_superuser=True) 

1357 

1358 with real_moderation_session(super_token) as api: 

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

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

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

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

1363 

1364 

1365def test_GetModerationState(db): 

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

1367 super_user, super_token = generate_user(is_superuser=True) 

1368 user1, token1 = generate_user() 

1369 user2, _ = generate_user() 

1370 

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

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

1373 

1374 with requests_session(token1) as api: 

1375 host_request_id = api.CreateHostRequest( 

1376 requests_pb2.CreateHostRequestReq( 

1377 host_user_id=user2.id, 

1378 from_date=today_plus_2, 

1379 to_date=today_plus_3, 

1380 text=valid_request_text(), 

1381 ) 

1382 ).host_request_id 

1383 

1384 with real_moderation_session(super_token) as api: 

1385 res = api.GetModerationState( 

1386 moderation_pb2.GetModerationStateReq( 

1387 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1388 object_id=host_request_id, 

1389 ) 

1390 ) 

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

1392 assert res.moderation_state.object_id == host_request_id 

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

1394 assert res.moderation_state.moderation_state_id > 0 

1395 

1396 

1397def test_GetModerationState_not_found(db): 

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

1399 super_user, super_token = generate_user(is_superuser=True) 

1400 

1401 with real_moderation_session(super_token) as api: 

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

1403 api.GetModerationState( 

1404 moderation_pb2.GetModerationStateReq( 

1405 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1406 object_id=999999, 

1407 ) 

1408 ) 

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

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

1411 

1412 

1413def test_GetModerationState_unspecified_type(db): 

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

1415 super_user, super_token = generate_user(is_superuser=True) 

1416 

1417 with real_moderation_session(super_token) as api: 

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

1419 api.GetModerationState( 

1420 moderation_pb2.GetModerationStateReq( 

1421 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED, 

1422 object_id=123, 

1423 ) 

1424 ) 

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

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

1427 

1428 

1429def test_ModerateContent_approve(db): 

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

1431 super_user, super_token = generate_user(is_superuser=True) 

1432 user1, token1 = generate_user() 

1433 user2, _ = generate_user() 

1434 

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

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

1437 

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

1439 with requests_session(token1) as api: 

1440 host_request_id = api.CreateHostRequest( 

1441 requests_pb2.CreateHostRequestReq( 

1442 host_user_id=user2.id, 

1443 from_date=today_plus_2, 

1444 to_date=today_plus_3, 

1445 text=valid_request_text(), 

1446 ) 

1447 ).host_request_id 

1448 

1449 # Get the moderation state ID 

1450 state_id = None 

1451 with session_scope() as session: 

1452 host_request = session.execute( 

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

1454 ).scalar_one() 

1455 state_id = host_request.moderation_state_id 

1456 

1457 with real_moderation_session(super_token) as api: 

1458 res = api.ModerateContent( 

1459 moderation_pb2.ModerateContentReq( 

1460 moderation_state_id=state_id, 

1461 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1462 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1463 reason="Approved by admin", 

1464 ) 

1465 ) 

1466 assert res.moderation_state.moderation_state_id == state_id 

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

1468 

1469 # Verify state was updated in database 

1470 with session_scope() as session: 

1471 state = session.get_one(ModerationState, state_id) 

1472 assert state.visibility == ModerationVisibility.visible 

1473 

1474 

1475def test_ModerateContent_not_found(db): 

1476 """Test moderating non-existent content""" 

1477 super_user, super_token = generate_user(is_superuser=True) 

1478 

1479 with real_moderation_session(super_token) as api: 

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

1481 api.ModerateContent( 

1482 moderation_pb2.ModerateContentReq( 

1483 moderation_state_id=999999, 

1484 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1485 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1486 reason="Test", 

1487 ) 

1488 ) 

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

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

1491 

1492 

1493def test_ModerateContent_hide(db): 

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

1495 super_user, super_token = generate_user(is_superuser=True) 

1496 normal_user, user_token = generate_user() 

1497 host, _ = generate_user() 

1498 

1499 # Create a real host request 

1500 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1501 

1502 with real_moderation_session(super_token) as api: 

1503 res = api.ModerateContent( 

1504 moderation_pb2.ModerateContentReq( 

1505 moderation_state_id=state_id, 

1506 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1507 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

1508 reason="Spam content", 

1509 ) 

1510 ) 

1511 assert res.moderation_state.moderation_state_id == state_id 

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

1513 

1514 # Verify state was updated in database 

1515 with session_scope() as session: 

1516 state = session.get_one(ModerationState, state_id) 

1517 assert state.visibility == ModerationVisibility.hidden 

1518 

1519 

1520def test_ModerateContent_shadow(db): 

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

1522 super_user, super_token = generate_user(is_superuser=True) 

1523 normal_user, user_token = generate_user() 

1524 host, _ = generate_user() 

1525 

1526 # Create a real host request 

1527 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1528 

1529 with real_moderation_session(super_token) as api: 

1530 res = api.ModerateContent( 

1531 moderation_pb2.ModerateContentReq( 

1532 moderation_state_id=state_id, 

1533 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1534 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

1535 reason="Needs further review", 

1536 ) 

1537 ) 

1538 assert res.moderation_state.moderation_state_id == state_id 

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

1540 

1541 # Verify state was updated in database 

1542 with session_scope() as session: 

1543 state = session.get_one(ModerationState, state_id) 

1544 assert state.visibility == ModerationVisibility.shadowed 

1545 

1546 

1547def test_FlagContentForReview(db): 

1548 """Test flagging content for review via admin API""" 

1549 super_user, super_token = generate_user(is_superuser=True) 

1550 user1, token1 = generate_user() 

1551 user2, _ = generate_user() 

1552 

1553 # Create a host request 

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

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

1556 

1557 with requests_session(token1) as api: 

1558 host_request_id = api.CreateHostRequest( 

1559 requests_pb2.CreateHostRequestReq( 

1560 host_user_id=user2.id, 

1561 from_date=today_plus_2, 

1562 to_date=today_plus_3, 

1563 text=valid_request_text(), 

1564 ) 

1565 ).host_request_id 

1566 

1567 # Get the moderation state ID 

1568 with session_scope() as session: 

1569 host_request = session.execute( 

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

1571 ).scalar_one() 

1572 state_id = host_request.moderation_state_id 

1573 

1574 with real_moderation_session(super_token) as api: 

1575 res = api.FlagContentForReview( 

1576 moderation_pb2.FlagContentForReviewReq( 

1577 moderation_state_id=state_id, 

1578 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

1579 reason="Admin flagged for additional review", 

1580 ) 

1581 ) 

1582 assert res.queue_item.moderation_state_id == state_id 

1583 assert res.queue_item.trigger == moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW 

1584 assert res.queue_item.is_resolved == False 

1585 

1586 # Verify queue item was created in database 

1587 with session_scope() as session: 

1588 # Get the most recent queue item (the one we just created) 

1589 queue_item = ( 

1590 session.execute( 

1591 select(ModerationQueueItem) 

1592 .where(ModerationQueueItem.moderation_state_id == state_id) 

1593 .order_by(ModerationQueueItem.time_created.desc()) 

1594 ) 

1595 .scalars() 

1596 .first() 

1597 ) 

1598 assert queue_item 

1599 assert queue_item.trigger == ModerationTrigger.moderator_review 

1600 assert queue_item.resolved_by_log_id is None 

1601 

1602 

1603# ============================================================================ 

1604# Tests for group chat moderation 

1605# ============================================================================ 

1606 

1607 

1608def test_group_chat_created_with_moderation_state(db): 

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

1610 user1, token1 = generate_user() 

1611 user2, _ = generate_user() 

1612 make_friends(user1, user2) 

1613 

1614 with conversations_session(token1) as api: 

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

1616 group_chat_id = res.group_chat_id 

1617 

1618 # Verify moderation state was created 

1619 with session_scope() as session: 

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

1621 

1622 assert group_chat.moderation_state.object_type == ModerationObjectType.group_chat 

1623 assert group_chat.moderation_state.object_id == group_chat_id 

1624 # Group chats start as SHADOWED 

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

1626 

1627 # A moderation queue item should have been created 

1628 queue_item = ( 

1629 session.execute( 

1630 select(ModerationQueueItem).where( 

1631 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id 

1632 ) 

1633 ) 

1634 .scalars() 

1635 .first() 

1636 ) 

1637 assert queue_item is not None 

1638 assert queue_item.trigger == ModerationTrigger.initial_review 

1639 

1640 

1641def test_group_chat_GetModerationState(db): 

1642 """Test GetModerationState API for group chats""" 

1643 user1, token1 = generate_user() 

1644 user2, _ = generate_user() 

1645 moderator, mod_token = generate_user(is_superuser=True) 

1646 make_friends(user1, user2) 

1647 

1648 with conversations_session(token1) as api: 

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

1650 group_chat_id = res.group_chat_id 

1651 

1652 # Moderator can look up the moderation state 

1653 with real_moderation_session(mod_token) as api: 

1654 res = api.GetModerationState( 

1655 moderation_pb2.GetModerationStateReq( 

1656 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1657 object_id=group_chat_id, 

1658 ) 

1659 ) 

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

1661 assert res.moderation_state.object_id == group_chat_id 

1662 # Starts as SHADOWED 

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

1664 

1665 

1666def test_group_chat_moderation_hide(db): 

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

1668 user1, token1 = generate_user() 

1669 user2, token2 = generate_user() 

1670 moderator, mod_token = generate_user(is_superuser=True) 

1671 make_friends(user1, user2) 

1672 

1673 with conversations_session(token1) as api: 

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

1675 group_chat_id = res.group_chat_id 

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

1677 

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

1679 with real_moderation_session(mod_token) as api: 

1680 state_res = api.GetModerationState( 

1681 moderation_pb2.GetModerationStateReq( 

1682 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1683 object_id=group_chat_id, 

1684 ) 

1685 ) 

1686 api.ModerateContent( 

1687 moderation_pb2.ModerateContentReq( 

1688 moderation_state_id=state_res.moderation_state.moderation_state_id, 

1689 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1690 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1691 reason="Approved", 

1692 ) 

1693 ) 

1694 

1695 # Both users can see the chat now 

1696 with conversations_session(token1) as api: 

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

1698 assert len(res.group_chats) == 1 

1699 

1700 with conversations_session(token2) as api: 

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

1702 assert len(res.group_chats) == 1 

1703 

1704 # Moderator hides the group chat 

1705 with real_moderation_session(mod_token) as api: 

1706 state_res = api.GetModerationState( 

1707 moderation_pb2.GetModerationStateReq( 

1708 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1709 object_id=group_chat_id, 

1710 ) 

1711 ) 

1712 api.ModerateContent( 

1713 moderation_pb2.ModerateContentReq( 

1714 moderation_state_id=state_res.moderation_state.moderation_state_id, 

1715 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1716 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

1717 reason="Inappropriate content", 

1718 ) 

1719 ) 

1720 

1721 # Neither user can see the chat now 

1722 with conversations_session(token1) as api: 

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

1724 assert len(res.group_chats) == 0 

1725 

1726 with conversations_session(token2) as api: 

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

1728 assert len(res.group_chats) == 0 

1729 

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

1731 with conversations_session(token1) as api: 

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

1733 assert len(res.messages) == 0 

1734 

1735 

1736def test_group_chat_moderation_shadow(db): 

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

1738 user1, token1 = generate_user() # Creator 

1739 user2, token2 = generate_user() # Participant 

1740 moderator, mod_token = generate_user(is_superuser=True) 

1741 make_friends(user1, user2) 

1742 

1743 with conversations_session(token1) as api: 

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

1745 group_chat_id = res.group_chat_id 

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

1747 

1748 # Moderator shadows the group chat 

1749 with real_moderation_session(mod_token) as api: 

1750 state_res = api.GetModerationState( 

1751 moderation_pb2.GetModerationStateReq( 

1752 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1753 object_id=group_chat_id, 

1754 ) 

1755 ) 

1756 api.ModerateContent( 

1757 moderation_pb2.ModerateContentReq( 

1758 moderation_state_id=state_res.moderation_state.moderation_state_id, 

1759 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1760 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

1761 reason="Needs review", 

1762 ) 

1763 ) 

1764 

1765 # Creator can see SHADOWED content in list operations 

1766 with conversations_session(token1) as api: 

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

1768 assert len(res.group_chats) == 1 

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

1770 

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

1772 with conversations_session(token2) as api: 

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

1774 assert len(res.group_chats) == 0 

1775 

1776 # Creator can also access it directly via GetGroupChat 

1777 with conversations_session(token1) as api: 

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

1779 assert res.group_chat_id == group_chat_id 

1780 

1781 

1782# ============================================================================ 

1783# Tests for auto-approval background job 

1784# ============================================================================ 

1785 

1786 

1787def test_auto_approve_moderation_queue_disabled_when_zero(db): 

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

1789 moderator, mod_token = generate_user(is_superuser=True) 

1790 user1, token1 = generate_user() 

1791 user2, token2 = generate_user() 

1792 

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

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

1795 

1796 # Create a host request 

1797 with requests_session(token1) as api: 

1798 with mock_notification_email() as mock: 

1799 host_request_id = api.CreateHostRequest( 

1800 requests_pb2.CreateHostRequestReq( 

1801 host_user_id=user2.id, 

1802 from_date=today_plus_2, 

1803 to_date=today_plus_3, 

1804 text=valid_request_text(), 

1805 ) 

1806 ).host_request_id 

1807 

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

1809 mock.assert_not_called() 

1810 

1811 # Ensure deadline is 0 (disabled) 

1812 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 0 

1813 

1814 # Run the job 

1815 auto_approve_moderation_queue(empty_pb2.Empty()) 

1816 

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

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

1819 assert res.host_request_id == host_request_id 

1820 

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

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

1823 assert len(res.host_requests) == 1 

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

1825 

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

1827 with requests_session(token2) as api: 

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

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

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

1831 

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

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

1834 assert len(res.host_requests) == 0 

1835 

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

1837 with real_moderation_session(mod_token) as api: 

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

1839 assert len(res.queue_items) == 1 

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

1841 

1842 # Moderator can check the state is still SHADOWED 

1843 state_res = api.GetModerationState( 

1844 moderation_pb2.GetModerationStateReq( 

1845 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1846 object_id=host_request_id, 

1847 ) 

1848 ) 

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

1850 

1851 

1852def test_auto_approve_moderation_queue_approves_old_items(db, push_collector: PushCollector): 

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

1854 moderator, mod_token = generate_user(is_superuser=True) 

1855 user1, token1 = generate_user() 

1856 user2, token2 = generate_user() 

1857 

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

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

1860 

1861 # Create a host request 

1862 with requests_session(token1) as api: 

1863 with mock_notification_email() as mock: 

1864 host_request_id = api.CreateHostRequest( 

1865 requests_pb2.CreateHostRequestReq( 

1866 host_user_id=user2.id, 

1867 from_date=today_plus_2, 

1868 to_date=today_plus_3, 

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

1870 ) 

1871 ).host_request_id 

1872 

1873 # No email sent initially (shadowed) 

1874 mock.assert_not_called() 

1875 

1876 # Host cannot see the request yet 

1877 with requests_session(token2) as api: 

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

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

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

1881 

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

1883 with session_scope() as session: 

1884 host_request = session.execute( 

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

1886 ).scalar_one() 

1887 queue_item = session.execute( 

1888 select(ModerationQueueItem) 

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

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

1891 ).scalar_one() 

1892 # Backdate the queue item by 2 minutes 

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

1894 

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

1896 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 60 

1897 config["MODERATION_BOT_USER_ID"] = moderator.id 

1898 

1899 # Run the job 

1900 auto_approve_moderation_queue(empty_pb2.Empty()) 

1901 

1902 # Now host can see the request via API 

1903 with requests_session(token2) as api: 

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

1905 assert res.host_request_id == host_request_id 

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

1907 

1908 # Host sees it in their received list 

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

1910 assert len(res.host_requests) == 1 

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

1912 

1913 # Surfer sees it in their sent list 

1914 with requests_session(token1) as api: 

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

1916 assert len(res.host_requests) == 1 

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

1918 

1919 # Moderator sees the queue item is now resolved 

1920 with real_moderation_session(mod_token) as api: 

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

1922 assert len(res.queue_items) == 0 

1923 

1924 # State is now VISIBLE 

1925 state_res = api.GetModerationState( 

1926 moderation_pb2.GetModerationStateReq( 

1927 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1928 object_id=host_request_id, 

1929 ) 

1930 ) 

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

1932 

1933 # Check the log shows auto-approval by the bot user 

1934 log_res = api.GetModerationLog( 

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

1936 ) 

1937 # Find the APPROVE action 

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

1939 assert len(approve_entries) == 1 

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

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

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

1943 

1944 

1945def test_auto_approve_does_not_approve_recent_items(db): 

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

1947 moderator, mod_token = generate_user(is_superuser=True) 

1948 user1, token1 = generate_user() 

1949 user2, token2 = generate_user() 

1950 

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

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

1953 

1954 # Create a host request 

1955 with requests_session(token1) as api: 

1956 with mock_notification_email() as mock: 

1957 host_request_id = api.CreateHostRequest( 

1958 requests_pb2.CreateHostRequestReq( 

1959 host_user_id=user2.id, 

1960 from_date=today_plus_2, 

1961 to_date=today_plus_3, 

1962 text=valid_request_text(), 

1963 ) 

1964 ).host_request_id 

1965 

1966 # No email sent (shadowed) 

1967 mock.assert_not_called() 

1968 

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

1970 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 3600 

1971 config["MODERATION_BOT_USER_ID"] = moderator.id 

1972 

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

1974 with mock_notification_email() as mock: 

1975 auto_approve_moderation_queue(empty_pb2.Empty()) 

1976 

1977 # Still no email sent 

1978 mock.assert_not_called() 

1979 

1980 # Host still cannot see the request 

1981 with requests_session(token2) as api: 

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

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

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

1985 

1986 # Not in host's received list 

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

1988 assert len(res.host_requests) == 0 

1989 

1990 # Moderator sees it still in queue unresolved 

1991 with real_moderation_session(mod_token) as api: 

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

1993 assert len(res.queue_items) == 1 

1994 

1995 # State is still SHADOWED 

1996 state_res = api.GetModerationState( 

1997 moderation_pb2.GetModerationStateReq( 

1998 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1999 object_id=host_request_id, 

2000 ) 

2001 ) 

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

2003 

2004 

2005def test_auto_approve_does_not_approve_already_approved(db): 

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

2007 moderator, mod_token = generate_user(is_superuser=True) 

2008 user1, token1 = generate_user() 

2009 user2, token2 = generate_user() 

2010 

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

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

2013 

2014 # Create a host request 

2015 with requests_session(token1) as api: 

2016 host_request_id = api.CreateHostRequest( 

2017 requests_pb2.CreateHostRequestReq( 

2018 host_user_id=user2.id, 

2019 from_date=today_plus_2, 

2020 to_date=today_plus_3, 

2021 text=valid_request_text(), 

2022 ) 

2023 ).host_request_id 

2024 

2025 # Moderator approves it manually 

2026 with real_moderation_session(mod_token) as api: 

2027 state_res = api.GetModerationState( 

2028 moderation_pb2.GetModerationStateReq( 

2029 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2030 object_id=host_request_id, 

2031 ) 

2032 ) 

2033 state_id = state_res.moderation_state.moderation_state_id 

2034 

2035 api.ModerateContent( 

2036 moderation_pb2.ModerateContentReq( 

2037 moderation_state_id=state_id, 

2038 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

2039 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

2040 reason="Approved by moderator", 

2041 ) 

2042 ) 

2043 

2044 # Host can now see it 

2045 with requests_session(token2) as api: 

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

2047 assert res.host_request_id == host_request_id 

2048 

2049 # Get log count before auto-approval 

2050 with real_moderation_session(mod_token) as api: 

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

2052 log_count_before = len(log_res_before.log_entries) 

2053 

2054 # Set deadline to 1 second 

2055 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1 

2056 config["MODERATION_BOT_USER_ID"] = moderator.id 

2057 

2058 # Run the job 

2059 auto_approve_moderation_queue(empty_pb2.Empty()) 

2060 

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

2062 with real_moderation_session(mod_token) as api: 

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

2064 assert len(log_res_after.log_entries) == log_count_before 

2065 

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

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

2068 assert len(queue_res.queue_items) == 0 

2069 

2070 

2071def test_auto_approve_does_not_approve_moderator_shadowed_items(db): 

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

2073 moderator, mod_token = generate_user(is_superuser=True) 

2074 user1, token1 = generate_user() 

2075 user2, token2 = generate_user() 

2076 

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

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

2079 

2080 # Create a host request 

2081 with requests_session(token1) as api: 

2082 host_request_id = api.CreateHostRequest( 

2083 requests_pb2.CreateHostRequestReq( 

2084 host_user_id=user2.id, 

2085 from_date=today_plus_2, 

2086 to_date=today_plus_3, 

2087 text=valid_request_text(), 

2088 ) 

2089 ).host_request_id 

2090 

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

2092 with real_moderation_session(mod_token) as api: 

2093 state_res = api.GetModerationState( 

2094 moderation_pb2.GetModerationStateReq( 

2095 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2096 object_id=host_request_id, 

2097 ) 

2098 ) 

2099 state_id = state_res.moderation_state.moderation_state_id 

2100 

2101 # Set to SHADOWED explicitly - this resolves the INITIAL_REVIEW queue item 

2102 api.ModerateContent( 

2103 moderation_pb2.ModerateContentReq( 

2104 moderation_state_id=state_id, 

2105 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2106 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

2107 reason="Keeping shadowed for review", 

2108 ) 

2109 ) 

2110 

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

2112 with session_scope() as session: 

2113 queue_item = session.execute( 

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

2115 ).scalar_one() 

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

2117 

2118 # Set deadline to 1 second 

2119 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1 

2120 config["MODERATION_BOT_USER_ID"] = moderator.id 

2121 

2122 # Get log count before 

2123 with real_moderation_session(mod_token) as api: 

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

2125 log_count_before = len(log_res_before.log_entries) 

2126 

2127 # Run the job 

2128 auto_approve_moderation_queue(empty_pb2.Empty()) 

2129 

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

2131 with real_moderation_session(mod_token) as api: 

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

2133 assert len(log_res_after.log_entries) == log_count_before 

2134 

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

2136 state_res = api.GetModerationState( 

2137 moderation_pb2.GetModerationStateReq( 

2138 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2139 object_id=host_request_id, 

2140 ) 

2141 ) 

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

2143 

2144 # Host still cannot see the request 

2145 with requests_session(token2) as api: 

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

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

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

2149 

2150 

2151# ============================================================================ 

2152# Notification Suppression Tests 

2153# ============================================================================ 

2154 

2155 

2156def test_host_request_message_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator): 

2157 """ 

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

2159 that haven't been approved yet. 

2160 """ 

2161 host, host_token = generate_user(complete_profile=True) 

2162 surfer, surfer_token = generate_user(complete_profile=True) 

2163 

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

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

2166 

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

2168 with requests_session(surfer_token) as api: 

2169 hr_id = api.CreateHostRequest( 

2170 requests_pb2.CreateHostRequestReq( 

2171 host_user_id=host.id, 

2172 from_date=today_plus_2, 

2173 to_date=today_plus_3, 

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

2175 ) 

2176 ).host_request_id 

2177 

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

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

2180 

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

2182 with requests_session(surfer_token) as api: 

2183 api.SendHostRequestMessage( 

2184 requests_pb2.SendHostRequestMessageReq( 

2185 host_request_id=hr_id, 

2186 text="Follow-up message 1", 

2187 ) 

2188 ) 

2189 api.SendHostRequestMessage( 

2190 requests_pb2.SendHostRequestMessageReq( 

2191 host_request_id=hr_id, 

2192 text="Follow-up message 2", 

2193 ) 

2194 ) 

2195 

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

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

2198 

2199 # Now approve the request 

2200 with mock_notification_email(): 

2201 moderator.approve_host_request(hr_id) 

2202 

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

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

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

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

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

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

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

2210 

2211 

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

2213 """ 

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

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

2216 

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

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

2219 """ 

2220 host, host_token = generate_user(complete_profile=True) 

2221 surfer, surfer_token = generate_user(complete_profile=True) 

2222 

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

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

2225 

2226 # Create host request 

2227 with requests_session(surfer_token) as api: 

2228 hr_id = api.CreateHostRequest( 

2229 requests_pb2.CreateHostRequestReq( 

2230 host_user_id=host.id, 

2231 from_date=today_plus_2, 

2232 to_date=today_plus_3, 

2233 text=valid_request_text(), 

2234 ) 

2235 ).host_request_id 

2236 

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

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

2239 

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

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

2242 with requests_session(surfer_token) as api: 

2243 api.RespondHostRequest( 

2244 requests_pb2.RespondHostRequestReq( 

2245 host_request_id=hr_id, 

2246 status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED, 

2247 text="Actually, never mind", 

2248 ) 

2249 ) 

2250 

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

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

2253 

2254 

2255def test_host_request_notifications_sent_after_approval(db, push_collector: PushCollector, moderator): 

2256 """ 

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

2258 """ 

2259 host, host_token = generate_user(complete_profile=True) 

2260 surfer, surfer_token = generate_user(complete_profile=True) 

2261 

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

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

2264 

2265 # Create and approve host request 

2266 with requests_session(surfer_token) as api: 

2267 hr_id = api.CreateHostRequest( 

2268 requests_pb2.CreateHostRequestReq( 

2269 host_user_id=host.id, 

2270 from_date=today_plus_2, 

2271 to_date=today_plus_3, 

2272 text=valid_request_text(), 

2273 ) 

2274 ).host_request_id 

2275 

2276 with mock_notification_email(): 

2277 moderator.approve_host_request(hr_id) 

2278 

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

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

2281 

2282 # Host accepts the request - surfer should be notified 

2283 with requests_session(host_token) as api: 

2284 with mock_notification_email(): 

2285 api.RespondHostRequest( 

2286 requests_pb2.RespondHostRequestReq( 

2287 host_request_id=hr_id, 

2288 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

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

2290 ) 

2291 ) 

2292 

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

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

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

2296 

2297 # Surfer confirms - host should be notified 

2298 with requests_session(surfer_token) as api: 

2299 with mock_notification_email(): 

2300 api.RespondHostRequest( 

2301 requests_pb2.RespondHostRequestReq( 

2302 host_request_id=hr_id, 

2303 status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED, 

2304 text="See you then!", 

2305 ) 

2306 ) 

2307 

2308 # Host should now have received the confirmation notifications 

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

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

2311 

2312 

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

2314 """ 

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

2316 that haven't been approved yet. 

2317 """ 

2318 user1, token1 = generate_user(complete_profile=True) 

2319 user2, token2 = generate_user(complete_profile=True) 

2320 

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

2322 with conversations_session(token1) as api: 

2323 res = api.CreateGroupChat( 

2324 conversations_pb2.CreateGroupChatReq( 

2325 recipient_user_ids=[user2.id], 

2326 ) 

2327 ) 

2328 gc_id = res.group_chat_id 

2329 

2330 # Verify initial state 

2331 with session_scope() as session: 

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

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

2334 

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

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

2337 

2338 # Send messages BEFORE approval 

2339 with conversations_session(token1) as api: 

2340 api.SendMessage( 

2341 conversations_pb2.SendMessageReq( 

2342 group_chat_id=gc_id, 

2343 text="Hello before approval", 

2344 ) 

2345 ) 

2346 

2347 # Process the queued notification job 

2348 while process_job(): 

2349 pass 

2350 

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

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

2353 

2354 # Now approve the group chat 

2355 moderator.approve_group_chat(gc_id) 

2356 

2357 # Process the queued notification jobs from approval 

2358 while process_job(): 

2359 pass 

2360 

2361 # Verify moderation state after approval 

2362 with session_scope() as session: 

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

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

2365 

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

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

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

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

2370 

2371 # Send a message AFTER approval 

2372 with conversations_session(token1) as api: 

2373 api.SendMessage( 

2374 conversations_pb2.SendMessageReq( 

2375 group_chat_id=gc_id, 

2376 text="Hello after approval", 

2377 ) 

2378 ) 

2379 

2380 # Process the queued notification job 

2381 while process_job(): 

2382 pass 

2383 

2384 # User2 should have received another notification 

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

2386 

2387 

2388def test_event_moderation_state_content(db): 

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

2390 super_user, super_token = generate_user(is_superuser=True) 

2391 user, token = generate_user() 

2392 

2393 with session_scope() as session: 

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

2395 

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

2397 end_time = start_time + timedelta(hours=3) 

2398 

2399 with events_session(token) as api: 

2400 res = api.CreateEvent( 

2401 events_pb2.CreateEventReq( 

2402 title="My Event Title", 

2403 content="My event description.", 

2404 photo_key=None, 

2405 offline_information=events_pb2.OfflineEventInformation( 

2406 address="Near Null Island", 

2407 lat=0.1, 

2408 lng=0.2, 

2409 ), 

2410 start_time=Timestamp_from_datetime(start_time), 

2411 end_time=Timestamp_from_datetime(end_time), 

2412 timezone="UTC", 

2413 ) 

2414 ) 

2415 event_id = res.event_id 

2416 

2417 with session_scope() as session: 

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

2419 state_id = occurrence.moderation_state_id 

2420 

2421 with real_moderation_session(super_token) as api: 

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

2423 event_items = [ 

2424 item 

2425 for item in res.queue_items 

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

2427 ] 

2428 assert len(event_items) == 1 

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

2430 

2431 

2432# ============================================================================ 

2433# Tests for SetUserContentVisibility 

2434# ============================================================================ 

2435 

2436 

2437def _get_moderation_state(session, object_type, object_id): 

2438 return session.execute( 

2439 select(ModerationState) 

2440 .where(ModerationState.object_type == object_type) 

2441 .where(ModerationState.object_id == object_id) 

2442 ).scalar_one() 

2443 

2444 

2445def test_SetUserContentVisibility_host_request(db): 

2446 super_user, super_token = generate_user(is_superuser=True) 

2447 surfer, surfer_token = generate_user() 

2448 host, _ = generate_user() 

2449 

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

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

2452 with requests_session(surfer_token) as api: 

2453 hr_id = api.CreateHostRequest( 

2454 requests_pb2.CreateHostRequestReq( 

2455 host_user_id=host.id, 

2456 from_date=today_plus_2, 

2457 to_date=today_plus_3, 

2458 text=valid_request_text(), 

2459 ) 

2460 ).host_request_id 

2461 

2462 with real_moderation_session(super_token) as api: 

2463 res = api.SetUserContentVisibility( 

2464 moderation_pb2.SetUserContentVisibilityReq( 

2465 user_id=surfer.id, 

2466 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

2467 ) 

2468 ) 

2469 # Already shadowed by default — no-op 

2470 assert res.updated_count == 0 

2471 

2472 with real_moderation_session(super_token) as api: 

2473 res = api.SetUserContentVisibility( 

2474 moderation_pb2.SetUserContentVisibilityReq( 

2475 user_id=surfer.id, 

2476 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2477 reason="policy violation", 

2478 ) 

2479 ) 

2480 assert res.updated_count == 1 

2481 

2482 with session_scope() as session: 

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

2484 assert state.visibility == ModerationVisibility.hidden 

2485 

2486 log_entries = ( 

2487 session.execute( 

2488 select(ModerationLog) 

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

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

2491 ) 

2492 .scalars() 

2493 .all() 

2494 ) 

2495 # create log + bulk update log 

2496 assert len(log_entries) == 2 

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

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

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

2500 

2501 

2502def test_SetUserContentVisibility_group_chat(db): 

2503 super_user, super_token = generate_user(is_superuser=True) 

2504 creator, creator_token = generate_user() 

2505 other, _ = generate_user() 

2506 make_friends(creator, other) 

2507 

2508 with conversations_session(creator_token) as api: 

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

2510 

2511 with real_moderation_session(super_token) as api: 

2512 api.SetUserContentVisibility( 

2513 moderation_pb2.SetUserContentVisibilityReq( 

2514 user_id=creator.id, 

2515 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2516 ) 

2517 ) 

2518 

2519 with session_scope() as session: 

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

2521 assert state.visibility == ModerationVisibility.hidden 

2522 

2523 

2524def test_SetUserContentVisibility_event_occurrence(db): 

2525 super_user, super_token = generate_user(is_superuser=True) 

2526 creator, creator_token = generate_user() 

2527 

2528 with session_scope() as session: 

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

2530 

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

2532 end_time = start_time + timedelta(hours=3) 

2533 with events_session(creator_token) as api: 

2534 event_id = api.CreateEvent( 

2535 events_pb2.CreateEventReq( 

2536 title="Event", 

2537 content="Event description.", 

2538 photo_key=None, 

2539 offline_information=events_pb2.OfflineEventInformation( 

2540 address="Near Null Island", 

2541 lat=0.1, 

2542 lng=0.2, 

2543 ), 

2544 start_time=Timestamp_from_datetime(start_time), 

2545 end_time=Timestamp_from_datetime(end_time), 

2546 timezone="UTC", 

2547 ) 

2548 ).event_id 

2549 

2550 with real_moderation_session(super_token) as api: 

2551 res = api.SetUserContentVisibility( 

2552 moderation_pb2.SetUserContentVisibilityReq( 

2553 user_id=creator.id, 

2554 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2555 ) 

2556 ) 

2557 assert res.updated_count == 1 

2558 

2559 with session_scope() as session: 

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

2561 assert state.visibility == ModerationVisibility.hidden 

2562 

2563 

2564def test_SetUserContentVisibility_friend_request(db): 

2565 super_user, super_token = generate_user(is_superuser=True) 

2566 sender, sender_token = generate_user() 

2567 recipient, _ = generate_user() 

2568 

2569 with api_session(sender_token) as api: 

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

2571 

2572 with session_scope() as session: 

2573 fr_id = session.execute( 

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

2575 ).scalar_one() 

2576 

2577 with real_moderation_session(super_token) as api: 

2578 res = api.SetUserContentVisibility( 

2579 moderation_pb2.SetUserContentVisibilityReq( 

2580 user_id=sender.id, 

2581 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2582 ) 

2583 ) 

2584 assert res.updated_count == 1 

2585 

2586 with session_scope() as session: 

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

2588 assert state.visibility == ModerationVisibility.hidden 

2589 

2590 

2591def test_SetUserContentVisibility_round_trip(db): 

2592 super_user, super_token = generate_user(is_superuser=True) 

2593 surfer, surfer_token = generate_user() 

2594 host, _ = generate_user() 

2595 

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

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

2598 with requests_session(surfer_token) as api: 

2599 hr_id = api.CreateHostRequest( 

2600 requests_pb2.CreateHostRequestReq( 

2601 host_user_id=host.id, 

2602 from_date=today_plus_2, 

2603 to_date=today_plus_3, 

2604 text=valid_request_text(), 

2605 ) 

2606 ).host_request_id 

2607 

2608 with real_moderation_session(super_token) as api: 

2609 api.SetUserContentVisibility( 

2610 moderation_pb2.SetUserContentVisibilityReq( 

2611 user_id=surfer.id, 

2612 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2613 reason="first", 

2614 ) 

2615 ) 

2616 api.SetUserContentVisibility( 

2617 moderation_pb2.SetUserContentVisibilityReq( 

2618 user_id=surfer.id, 

2619 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

2620 reason="second", 

2621 ) 

2622 ) 

2623 

2624 with session_scope() as session: 

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

2626 assert state.visibility == ModerationVisibility.visible 

2627 

2628 bulk_log_entries = ( 

2629 session.execute( 

2630 select(ModerationLog) 

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

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

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

2634 ) 

2635 .scalars() 

2636 .all() 

2637 ) 

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

2639 ModerationVisibility.hidden, 

2640 ModerationVisibility.visible, 

2641 ] 

2642 

2643 

2644def test_SetUserContentVisibility_resolves_queue_items(db): 

2645 super_user, super_token = generate_user(is_superuser=True) 

2646 surfer, surfer_token = generate_user() 

2647 host, _ = generate_user() 

2648 

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

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

2651 with requests_session(surfer_token) as api: 

2652 hr_id = api.CreateHostRequest( 

2653 requests_pb2.CreateHostRequestReq( 

2654 host_user_id=host.id, 

2655 from_date=today_plus_2, 

2656 to_date=today_plus_3, 

2657 text=valid_request_text(), 

2658 ) 

2659 ).host_request_id 

2660 

2661 with session_scope() as session: 

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

2663 queue_item = session.execute( 

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

2665 ).scalar_one() 

2666 assert queue_item.resolved_by_log_id is None 

2667 

2668 with real_moderation_session(super_token) as api: 

2669 api.SetUserContentVisibility( 

2670 moderation_pb2.SetUserContentVisibilityReq( 

2671 user_id=surfer.id, 

2672 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2673 ) 

2674 ) 

2675 

2676 with session_scope() as session: 

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

2678 queue_item = session.execute( 

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

2680 ).scalar_one() 

2681 assert queue_item.resolved_by_log_id is not None 

2682 

2683 

2684def test_SetUserContentVisibility_noop_when_matches(db): 

2685 super_user, super_token = generate_user(is_superuser=True) 

2686 surfer, surfer_token = generate_user() 

2687 host, _ = generate_user() 

2688 

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

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

2691 with requests_session(surfer_token) as api: 

2692 api.CreateHostRequest( 

2693 requests_pb2.CreateHostRequestReq( 

2694 host_user_id=host.id, 

2695 from_date=today_plus_2, 

2696 to_date=today_plus_3, 

2697 text=valid_request_text(), 

2698 ) 

2699 ) 

2700 

2701 with session_scope() as session: 

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

2703 

2704 with real_moderation_session(super_token) as api: 

2705 res = api.SetUserContentVisibility( 

2706 moderation_pb2.SetUserContentVisibilityReq( 

2707 user_id=surfer.id, 

2708 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

2709 ) 

2710 ) 

2711 assert res.updated_count == 0 

2712 

2713 with session_scope() as session: 

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

2715 assert log_count_after == log_count_before 

2716 

2717 

2718def test_SetUserContentVisibility_unspecified_rejected(db): 

2719 super_user, super_token = generate_user(is_superuser=True) 

2720 target, _ = generate_user() 

2721 

2722 with real_moderation_session(super_token) as api: 

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

2724 api.SetUserContentVisibility( 

2725 moderation_pb2.SetUserContentVisibilityReq( 

2726 user_id=target.id, 

2727 visibility=moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED, 

2728 ) 

2729 ) 

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

2731 

2732 

2733def test_SetUserContentVisibility_non_admin_rejected(db): 

2734 normal_user, normal_token = generate_user() 

2735 target, _ = generate_user() 

2736 

2737 with real_moderation_session(normal_token) as api: 

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

2739 api.SetUserContentVisibility( 

2740 moderation_pb2.SetUserContentVisibilityReq( 

2741 user_id=target.id, 

2742 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2743 ) 

2744 ) 

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

2746 

2747 

2748def test_SetUserContentVisibility_writes_admin_action(db): 

2749 super_user, super_token = generate_user(is_superuser=True) 

2750 surfer, surfer_token = generate_user() 

2751 host, _ = generate_user() 

2752 

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

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

2755 with requests_session(surfer_token) as api: 

2756 api.CreateHostRequest( 

2757 requests_pb2.CreateHostRequestReq( 

2758 host_user_id=host.id, 

2759 from_date=today_plus_2, 

2760 to_date=today_plus_3, 

2761 text=valid_request_text(), 

2762 ) 

2763 ) 

2764 

2765 with real_moderation_session(super_token) as api: 

2766 api.SetUserContentVisibility( 

2767 moderation_pb2.SetUserContentVisibilityReq( 

2768 user_id=surfer.id, 

2769 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2770 reason="bulk hide", 

2771 ) 

2772 ) 

2773 

2774 with session_scope() as session: 

2775 actions = ( 

2776 session.execute( 

2777 select(AdminAction) 

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

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

2780 ) 

2781 .scalars() 

2782 .all() 

2783 ) 

2784 assert len(actions) == 1 

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

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

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

2788 

2789 

2790def test_SetUserContentVisibility_only_touches_target(db): 

2791 super_user, super_token = generate_user(is_superuser=True) 

2792 target, target_token = generate_user() 

2793 other, other_token = generate_user() 

2794 host, _ = generate_user() 

2795 

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

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

2798 

2799 with requests_session(target_token) as api: 

2800 target_hr_id = api.CreateHostRequest( 

2801 requests_pb2.CreateHostRequestReq( 

2802 host_user_id=host.id, 

2803 from_date=today_plus_2, 

2804 to_date=today_plus_3, 

2805 text=valid_request_text(), 

2806 ) 

2807 ).host_request_id 

2808 

2809 with requests_session(other_token) as api: 

2810 other_hr_id = api.CreateHostRequest( 

2811 requests_pb2.CreateHostRequestReq( 

2812 host_user_id=host.id, 

2813 from_date=today_plus_2, 

2814 to_date=today_plus_3, 

2815 text=valid_request_text(), 

2816 ) 

2817 ).host_request_id 

2818 

2819 with real_moderation_session(super_token) as api: 

2820 res = api.SetUserContentVisibility( 

2821 moderation_pb2.SetUserContentVisibilityReq( 

2822 user_id=target.id, 

2823 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2824 ) 

2825 ) 

2826 assert res.updated_count == 1 

2827 

2828 with session_scope() as session: 

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

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

2831 assert target_state.visibility == ModerationVisibility.hidden 

2832 assert other_state.visibility == ModerationVisibility.shadowed 

2833 

2834 

2835def test_SetUserContentVisibility_user_not_found(db): 

2836 super_user, super_token = generate_user(is_superuser=True) 

2837 

2838 with real_moderation_session(super_token) as api: 

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

2840 api.SetUserContentVisibility( 

2841 moderation_pb2.SetUserContentVisibilityReq( 

2842 user_id=999999, 

2843 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

2844 ) 

2845 ) 

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