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

1011 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 14:14 +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.models import ( 

16 EventOccurrence, 

17 GroupChat, 

18 HostRequest, 

19 ModerationAction, 

20 ModerationLog, 

21 ModerationObjectType, 

22 ModerationQueueItem, 

23 ModerationState, 

24 ModerationTrigger, 

25 ModerationVisibility, 

26) 

27from couchers.moderation.utils import create_moderation 

28from couchers.proto import conversations_pb2, events_pb2, moderation_pb2, notifications_pb2, requests_pb2 

29from couchers.utils import Timestamp_from_datetime, now, today 

30from tests.fixtures.db import generate_user, make_friends 

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

32from tests.fixtures.sessions import ( 

33 conversations_session, 

34 events_session, 

35 notifications_session, 

36 real_moderation_session, 

37 requests_session, 

38) 

39from tests.test_communities import create_community 

40from tests.test_requests import valid_request_text 

41 

42 

43@pytest.fixture(autouse=True) 

44def _(testconfig): 

45 pass 

46 

47 

48def create_test_host_request_with_moderation(surfer_token, host_user_id): 

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

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

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

52 

53 with requests_session(surfer_token) as api: 

54 hr_id = api.CreateHostRequest( 

55 requests_pb2.CreateHostRequestReq( 

56 host_user_id=host_user_id, 

57 from_date=today_plus_2, 

58 to_date=today_plus_3, 

59 text=valid_request_text(), 

60 ) 

61 ).host_request_id 

62 

63 with session_scope() as session: 

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

65 return hr.moderation_state_id 

66 

67 

68# ============================================================================ 

69# Tests for moderation helper functions 

70# ============================================================================ 

71 

72 

73def test_create_moderation(db): 

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

75 user, _ = generate_user() 

76 

77 with session_scope() as session: 

78 # Create a moderation state 

79 moderation_state = create_moderation( 

80 session=session, 

81 object_type=ModerationObjectType.host_request, 

82 object_id=123, 

83 creator_user_id=user.id, 

84 ) 

85 

86 assert moderation_state.object_type == ModerationObjectType.host_request 

87 assert moderation_state.object_id == 123 

88 assert moderation_state.visibility == ModerationVisibility.shadowed 

89 

90 # Check that log entry was created 

91 log_entries = ( 

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

93 .scalars() 

94 .all() 

95 ) 

96 

97 assert len(log_entries) == 1 

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

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

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

101 

102 

103def test_add_to_moderation_queue(db): 

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

105 super_user, super_token = generate_user(is_superuser=True) 

106 user1, token1 = generate_user() 

107 user2, _ = generate_user() 

108 

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

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

111 

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

113 with requests_session(token1) as api: 

114 host_request_id = api.CreateHostRequest( 

115 requests_pb2.CreateHostRequestReq( 

116 host_user_id=user2.id, 

117 from_date=today_plus_2, 

118 to_date=today_plus_3, 

119 text=valid_request_text(), 

120 ) 

121 ).host_request_id 

122 

123 # Get the moderation state ID 

124 state_id = None 

125 with session_scope() as session: 

126 host_request = session.execute( 

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

128 ).scalar_one() 

129 state_id = host_request.moderation_state_id 

130 

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

132 with real_moderation_session(super_token) as api: 

133 res = api.FlagContentForReview( 

134 moderation_pb2.FlagContentForReviewReq( 

135 moderation_state_id=state_id, 

136 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

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

138 ) 

139 ) 

140 

141 assert res.queue_item.moderation_state_id == state_id 

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

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

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

145 assert res.queue_item.is_resolved == False 

146 

147 

148def test_moderate_content(db): 

149 """Test moderating content via API""" 

150 super_user, super_token = generate_user(is_superuser=True) 

151 user, token = generate_user() 

152 host, _ = generate_user() 

153 

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

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

156 

157 # Create a real host request 

158 state_id = None 

159 with requests_session(token) as api: 

160 hr_id = api.CreateHostRequest( 

161 requests_pb2.CreateHostRequestReq( 

162 host_user_id=host.id, 

163 from_date=today_plus_2, 

164 to_date=today_plus_3, 

165 text=valid_request_text(), 

166 ) 

167 ).host_request_id 

168 

169 with session_scope() as session: 

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

171 state_id = hr.moderation_state_id 

172 

173 # Moderate the content via API 

174 with real_moderation_session(super_token) as api: 

175 res = api.ModerateContent( 

176 moderation_pb2.ModerateContentReq( 

177 moderation_state_id=state_id, 

178 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

179 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

180 reason="Content looks good", 

181 ) 

182 ) 

183 

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

185 

186 # Check that state was updated in database 

187 with session_scope() as session: 

188 updated_state = session.get_one(ModerationState, state_id) 

189 assert updated_state.visibility == ModerationVisibility.visible 

190 

191 # Check that log entry was created 

192 log_entries = ( 

193 session.execute( 

194 select(ModerationLog) 

195 .where(ModerationLog.moderation_state_id == state_id) 

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

197 ) 

198 .scalars() 

199 .all() 

200 ) 

201 

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

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

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

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

206 

207 

208def test_resolve_queue_item(db): 

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

210 user1, token1 = generate_user() 

211 user2, _ = generate_user() 

212 moderator, moderator_token = generate_user(is_superuser=True) 

213 

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

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

216 

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

218 with requests_session(token1) as api: 

219 host_request_id = api.CreateHostRequest( 

220 requests_pb2.CreateHostRequestReq( 

221 host_user_id=user2.id, 

222 from_date=today_plus_2, 

223 to_date=today_plus_3, 

224 text=valid_request_text(), 

225 ) 

226 ).host_request_id 

227 

228 state_id = None 

229 with session_scope() as session: 

230 # Get the host request and its moderation state 

231 host_request = session.execute( 

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

233 ).scalar_one() 

234 state_id = host_request.moderation_state_id 

235 

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

237 queue_item = session.execute( 

238 select(ModerationQueueItem) 

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

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

241 ).scalar_one() 

242 

243 assert queue_item.resolved_by_log_id is None 

244 

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

246 with real_moderation_session(moderator_token) as api: 

247 api.ModerateContent( 

248 moderation_pb2.ModerateContentReq( 

249 moderation_state_id=state_id, 

250 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

251 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

252 reason="Approved after review", 

253 ) 

254 ) 

255 

256 # Check that queue item was resolved 

257 with session_scope() as session: 

258 queue_item = session.execute( 

259 select(ModerationQueueItem) 

260 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

262 ).scalar_one() 

263 assert queue_item.resolved_by_log_id is not None 

264 

265 

266def test_approve_content_via_api(db): 

267 """Test approving content via ModerateContent API""" 

268 user1, token1 = generate_user() 

269 user2, _ = generate_user() 

270 moderator, moderator_token = generate_user(is_superuser=True) 

271 

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

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

274 

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

276 with requests_session(token1) as api: 

277 host_request_id = api.CreateHostRequest( 

278 requests_pb2.CreateHostRequestReq( 

279 host_user_id=user2.id, 

280 from_date=today_plus_2, 

281 to_date=today_plus_3, 

282 text=valid_request_text(), 

283 ) 

284 ).host_request_id 

285 

286 state_id = None 

287 with session_scope() as session: 

288 # Get the host request and its moderation state 

289 host_request = session.execute( 

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

291 ).scalar_one() 

292 state_id = host_request.moderation_state_id 

293 

294 # Approve via API 

295 with real_moderation_session(moderator_token) as api: 

296 api.ModerateContent( 

297 moderation_pb2.ModerateContentReq( 

298 moderation_state_id=state_id, 

299 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

300 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

301 reason="Quick approval", 

302 ) 

303 ) 

304 

305 # Check that state was updated to VISIBLE 

306 with session_scope() as session: 

307 updated_state = session.get_one(ModerationState, state_id) 

308 assert updated_state.visibility == ModerationVisibility.visible 

309 

310 # Check log entry 

311 log_entry = session.execute( 

312 select(ModerationLog) 

313 .where(ModerationLog.moderation_state_id == state_id) 

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

315 ).scalar_one() 

316 

317 assert log_entry.moderator_user_id == moderator.id 

318 assert log_entry.reason == "Quick approval" 

319 

320 

321# ============================================================================ 

322# Tests for host request moderation integration 

323# ============================================================================ 

324 

325 

326def test_create_host_request_creates_moderation_state(db): 

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

328 user1, token1 = generate_user() 

329 user2, token2 = generate_user() 

330 

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

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

333 

334 with requests_session(token1) as api: 

335 host_request_id = api.CreateHostRequest( 

336 requests_pb2.CreateHostRequestReq( 

337 host_user_id=user2.id, 

338 from_date=today_plus_2, 

339 to_date=today_plus_3, 

340 text=valid_request_text(), 

341 ) 

342 ).host_request_id 

343 

344 with session_scope() as session: 

345 # Check that host request has a moderation state 

346 host_request = session.execute( 

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

348 ).scalar_one() 

349 

350 # Check moderation state properties 

351 moderation_state = session.execute( 

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

353 ).scalar_one() 

354 

355 assert moderation_state.object_type == ModerationObjectType.host_request 

356 assert moderation_state.object_id == host_request_id 

357 assert moderation_state.visibility == ModerationVisibility.shadowed 

358 

359 # Check that it was added to moderation queue 

360 queue_items = ( 

361 session.execute( 

362 select(ModerationQueueItem) 

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

364 .where(ModerationQueueItem.resolved_by_log_id == None) 

365 ) 

366 .scalars() 

367 .all() 

368 ) 

369 

370 assert len(queue_items) == 1 

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

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

373 

374 

375def test_host_request_no_notification_before_approval(db, push_collector: PushCollector): 

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

377 user1, token1 = generate_user() 

378 user2, token2 = generate_user() 

379 

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

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

382 

383 with requests_session(token1) as api: 

384 host_request_id = api.CreateHostRequest( 

385 requests_pb2.CreateHostRequestReq( 

386 host_user_id=user2.id, 

387 from_date=today_plus_2, 

388 to_date=today_plus_3, 

389 text=valid_request_text(), 

390 ) 

391 ).host_request_id 

392 

393 # Process all jobs (including the notification job) 

394 process_jobs() 

395 

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

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

398 

399 

400def test_shadowed_notification_not_in_list_notifications(db): 

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

402 user1, token1 = generate_user() 

403 user2, token2 = generate_user() 

404 

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

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

407 

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

409 with requests_session(token1) as api: 

410 host_request_id = api.CreateHostRequest( 

411 requests_pb2.CreateHostRequestReq( 

412 host_user_id=user2.id, 

413 from_date=today_plus_2, 

414 to_date=today_plus_3, 

415 text=valid_request_text(), 

416 ) 

417 ).host_request_id 

418 

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

420 with notifications_session(token2) as api: 

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

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

423 assert len(res.notifications) == 0 

424 

425 

426def test_notification_visible_after_approval(db): 

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

428 user1, token1 = generate_user() 

429 user2, token2 = generate_user() 

430 mod, mod_token = generate_user(is_superuser=True) 

431 

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

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

434 

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

436 with requests_session(token1) as api: 

437 host_request_id = api.CreateHostRequest( 

438 requests_pb2.CreateHostRequestReq( 

439 host_user_id=user2.id, 

440 from_date=today_plus_2, 

441 to_date=today_plus_3, 

442 text=valid_request_text(), 

443 ) 

444 ).host_request_id 

445 

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

447 with notifications_session(token2) as api: 

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

449 assert len(res.notifications) == 0 

450 

451 # Get the moderation state ID and approve 

452 with session_scope() as session: 

453 host_request = session.execute( 

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

455 ).scalar_one() 

456 state_id = host_request.moderation_state_id 

457 

458 with real_moderation_session(mod_token) as api: 

459 api.ModerateContent( 

460 moderation_pb2.ModerateContentReq( 

461 moderation_state_id=state_id, 

462 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

463 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

464 reason="Looks good", 

465 ) 

466 ) 

467 

468 # Now host SHOULD see the notification 

469 with notifications_session(token2) as api: 

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

471 assert len(res.notifications) == 1 

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

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

474 

475 

476def test_shadowed_host_request_visible_to_author_only(db): 

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

478 user1, token1 = generate_user() 

479 user2, token2 = generate_user() 

480 

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

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

483 

484 with requests_session(token1) as api: 

485 host_request_id = api.CreateHostRequest( 

486 requests_pb2.CreateHostRequestReq( 

487 host_user_id=user2.id, 

488 from_date=today_plus_2, 

489 to_date=today_plus_3, 

490 text=valid_request_text(), 

491 ) 

492 ).host_request_id 

493 

494 # Surfer (author) can see it with GetHostRequest 

495 with requests_session(token1) as api: 

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

497 assert res.host_request_id == host_request_id 

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

499 

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

501 with requests_session(token2) as api: 

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

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

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

505 

506 

507def test_unlisted_host_request_not_in_lists(db): 

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

509 user1, token1 = generate_user() 

510 user2, token2 = generate_user() 

511 

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

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

514 

515 with requests_session(token1) as api: 

516 host_request_id = api.CreateHostRequest( 

517 requests_pb2.CreateHostRequestReq( 

518 host_user_id=user2.id, 

519 from_date=today_plus_2, 

520 to_date=today_plus_3, 

521 text=valid_request_text(), 

522 ) 

523 ).host_request_id 

524 

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

526 with requests_session(token1) as api: 

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

528 assert len(res.host_requests) == 1 

529 

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

531 with requests_session(token2) as api: 

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

533 assert len(res.host_requests) == 0 

534 

535 

536def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector): 

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

538 user1, token1 = generate_user() 

539 user2, token2 = generate_user() 

540 mod, mod_token = generate_user(is_superuser=True) 

541 

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

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

544 

545 with requests_session(token1) as api: 

546 host_request_id = api.CreateHostRequest( 

547 requests_pb2.CreateHostRequestReq( 

548 host_user_id=user2.id, 

549 from_date=today_plus_2, 

550 to_date=today_plus_3, 

551 text=valid_request_text(), 

552 ) 

553 ).host_request_id 

554 

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

556 process_jobs() 

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

558 

559 # Get the moderation state ID 

560 state_id = None 

561 with session_scope() as session: 

562 host_request = session.execute( 

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

564 ).scalar_one() 

565 state_id = host_request.moderation_state_id 

566 

567 # Approve the host request via API 

568 with real_moderation_session(mod_token) as api: 

569 api.ModerateContent( 

570 moderation_pb2.ModerateContentReq( 

571 moderation_state_id=state_id, 

572 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

573 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

574 reason="Looks good", 

575 ) 

576 ) 

577 

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

579 process_jobs() 

580 

581 # Now surfer SHOULD see it in their sent list 

582 with requests_session(token1) as api: 

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

584 assert len(res.host_requests) == 1 

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

586 

587 # Host SHOULD see it in their received list 

588 with requests_session(token2) as api: 

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

590 assert len(res.host_requests) == 1 

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

592 

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

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

595 

596 

597def test_hidden_host_request_invisible_to_all(db): 

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

599 user1, token1 = generate_user() 

600 user2, token2 = generate_user() 

601 user3, token3 = generate_user() # Third party 

602 moderator, moderator_token = generate_user(is_superuser=True) 

603 

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

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

606 

607 with requests_session(token1) as api: 

608 host_request_id = api.CreateHostRequest( 

609 requests_pb2.CreateHostRequestReq( 

610 host_user_id=user2.id, 

611 from_date=today_plus_2, 

612 to_date=today_plus_3, 

613 text=valid_request_text(), 

614 ) 

615 ).host_request_id 

616 

617 # Get the moderation state ID 

618 state_id = None 

619 with session_scope() as session: 

620 host_request = session.execute( 

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

622 ).scalar_one() 

623 state_id = host_request.moderation_state_id 

624 

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

626 with real_moderation_session(moderator_token) as api: 

627 api.ModerateContent( 

628 moderation_pb2.ModerateContentReq( 

629 moderation_state_id=state_id, 

630 action=moderation_pb2.MODERATION_ACTION_HIDE, 

631 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

632 reason="Spam content", 

633 ) 

634 ) 

635 

636 # Surfer can't see it with GetHostRequest 

637 with requests_session(token1) as api: 

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

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

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

641 

642 # Host can't see it with GetHostRequest 

643 with requests_session(token2) as api: 

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

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

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

647 

648 # Third party definitely can't see it 

649 with requests_session(token3) as api: 

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

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

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

653 

654 # Not in any lists 

655 with requests_session(token1) as api: 

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

657 assert len(res.host_requests) == 0 

658 

659 with requests_session(token2) as api: 

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

661 assert len(res.host_requests) == 0 

662 

663 

664def test_multiple_host_requests_listing_visibility(db): 

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

666 user1, token1 = generate_user() 

667 user2, token2 = generate_user() 

668 moderator, moderator_token = generate_user(is_superuser=True) 

669 

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

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

672 

673 # Create 3 host requests 

674 host_request_ids = [] 

675 state_ids = [] 

676 with requests_session(token1) as api: 

677 for i in range(3): 

678 hr_id = api.CreateHostRequest( 

679 requests_pb2.CreateHostRequestReq( 

680 host_user_id=user2.id, 

681 from_date=today_plus_2, 

682 to_date=today_plus_3, 

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

684 ) 

685 ).host_request_id 

686 host_request_ids.append(hr_id) 

687 

688 # Get state IDs 

689 with session_scope() as session: 

690 for hr_id in host_request_ids: 

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

692 state_ids.append(host_request.moderation_state_id) 

693 

694 # Approve the first one via API 

695 with real_moderation_session(moderator_token) as api: 

696 api.ModerateContent( 

697 moderation_pb2.ModerateContentReq( 

698 moderation_state_id=state_ids[0], 

699 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

700 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

701 reason="Approved", 

702 ) 

703 ) 

704 

705 # Hide the third one via API 

706 with real_moderation_session(moderator_token) as api: 

707 api.ModerateContent( 

708 moderation_pb2.ModerateContentReq( 

709 moderation_state_id=state_ids[2], 

710 action=moderation_pb2.MODERATION_ACTION_HIDE, 

711 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

712 reason="Spam", 

713 ) 

714 ) 

715 

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

717 with requests_session(token1) as api: 

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

719 assert len(res.host_requests) == 2 

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

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

722 

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

724 with requests_session(token2) as api: 

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

726 assert len(res.host_requests) == 1 

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

728 

729 

730def test_moderation_log_tracking(db): 

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

732 user, user_token = generate_user() 

733 host, _ = generate_user() 

734 moderator1, moderator1_token = generate_user(is_superuser=True) 

735 moderator2, moderator2_token = generate_user(is_superuser=True) 

736 

737 # Create a real host request 

738 state_id = create_test_host_request_with_moderation(user_token, host.id) 

739 

740 # Perform several moderation actions via API 

741 with real_moderation_session(moderator1_token) as api: 

742 api.ModerateContent( 

743 moderation_pb2.ModerateContentReq( 

744 moderation_state_id=state_id, 

745 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

746 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

747 reason="Looks good initially", 

748 ) 

749 ) 

750 

751 with real_moderation_session(moderator2_token) as api: 

752 api.FlagContentForReview( 

753 moderation_pb2.FlagContentForReviewReq( 

754 moderation_state_id=state_id, 

755 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

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

757 ) 

758 ) 

759 # Shadow it back 

760 api.ModerateContent( 

761 moderation_pb2.ModerateContentReq( 

762 moderation_state_id=state_id, 

763 action=moderation_pb2.MODERATION_ACTION_HIDE, 

764 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

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

766 ) 

767 ) 

768 

769 with real_moderation_session(moderator1_token) as api: 

770 api.ModerateContent( 

771 moderation_pb2.ModerateContentReq( 

772 moderation_state_id=state_id, 

773 action=moderation_pb2.MODERATION_ACTION_HIDE, 

774 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

775 reason="Actually it's spam", 

776 ) 

777 ) 

778 

779 # Check all log entries 

780 with session_scope() as session: 

781 log_entries = ( 

782 session.execute( 

783 select(ModerationLog) 

784 .where(ModerationLog.moderation_state_id == state_id) 

785 .order_by(ModerationLog.time.asc()) 

786 ) 

787 .scalars() 

788 .all() 

789 ) 

790 

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

792 assert len(log_entries) >= 3 

793 

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

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

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

797 

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

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

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

801 

802 # The last action should be hiding 

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

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

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

806 

807 

808def test_moderation_queue_workflow(db): 

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

810 user1, token1 = generate_user() 

811 user2, _ = generate_user() 

812 moderator, moderator_token = generate_user(is_superuser=True) 

813 

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

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

816 

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

818 with requests_session(token1) as api: 

819 host_request_id = api.CreateHostRequest( 

820 requests_pb2.CreateHostRequestReq( 

821 host_user_id=user2.id, 

822 from_date=today_plus_2, 

823 to_date=today_plus_3, 

824 text=valid_request_text(), 

825 ) 

826 ).host_request_id 

827 

828 state_id = None 

829 queue_item_id = None 

830 with session_scope() as session: 

831 # Get the host request and its moderation state 

832 host_request = session.execute( 

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

834 ).scalar_one() 

835 state_id = host_request.moderation_state_id 

836 

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

838 queue_item = session.execute( 

839 select(ModerationQueueItem) 

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

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

842 ).scalar_one() 

843 queue_item_id = queue_item.id 

844 

845 # Verify it's in the queue 

846 unresolved_items = ( 

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

848 .scalars() 

849 .all() 

850 ) 

851 

852 assert len(unresolved_items) >= 1 

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

854 

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

856 with real_moderation_session(moderator_token) as api: 

857 api.ModerateContent( 

858 moderation_pb2.ModerateContentReq( 

859 moderation_state_id=state_id, 

860 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

861 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

862 reason="Content approved", 

863 ) 

864 ) 

865 

866 # Verify queue item was resolved 

867 with session_scope() as session: 

868 # Verify it's no longer in unresolved queue 

869 unresolved_items = ( 

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

871 .scalars() 

872 .all() 

873 ) 

874 

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

876 

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

878 queue_item = session.get_one(ModerationQueueItem, queue_item_id) 

879 assert queue_item.resolved_by_log_id is not None 

880 

881 

882# ============================================================================ 

883# Moderation API Tests (testing the gRPC servicer) 

884# ============================================================================ 

885 

886 

887def test_GetModerationQueue_empty(db): 

888 """Test getting an empty moderation queue""" 

889 super_user, super_token = generate_user(is_superuser=True) 

890 

891 with real_moderation_session(super_token) as api: 

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

893 assert len(res.queue_items) == 0 

894 assert res.next_page_token == "" 

895 

896 

897def test_GetModerationQueue_with_items(db): 

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

899 super_user, super_token = generate_user(is_superuser=True) 

900 normal_user, user_token = generate_user() 

901 host, _ = generate_user() 

902 

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

904 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

905 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

906 

907 with real_moderation_session(super_token) as api: 

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

909 assert len(res.queue_items) == 2 

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

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

912 

913 

914def test_GetModerationQueue_filter_by_trigger(db): 

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

916 super_user, super_token = generate_user(is_superuser=True) 

917 normal_user, user_token = generate_user() 

918 host, _ = generate_user() 

919 

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

921 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

922 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

923 

924 # Add USER_FLAG trigger to second item via API 

925 with real_moderation_session(super_token) as api: 

926 api.FlagContentForReview( 

927 moderation_pb2.FlagContentForReviewReq( 

928 moderation_state_id=state2_id, 

929 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

930 reason="Reported by user", 

931 ) 

932 ) 

933 

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

935 with real_moderation_session(super_token) as api: 

936 res = api.GetModerationQueue( 

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

938 ) 

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

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

941 

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

943 with real_moderation_session(super_token) as api: 

944 res = api.GetModerationQueue( 

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

946 ) 

947 assert len(res.queue_items) == 1 

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

949 

950 

951def test_GetModerationQueue_filter_created_before(db): 

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

953 super_user, super_token = generate_user(is_superuser=True) 

954 normal_user, user_token = generate_user() 

955 host, _ = generate_user() 

956 

957 # Create host requests 

958 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

959 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

960 

961 # Backdate the first queue item 

962 with session_scope() as session: 

963 queue_item1 = session.execute( 

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

965 ).scalar_one() 

966 # Set it to 2 hours ago 

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

968 

969 # The second item remains at current time 

970 

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

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

973 with real_moderation_session(super_token) as api: 

974 res = api.GetModerationQueue( 

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

976 ) 

977 assert len(res.queue_items) == 1 

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

979 

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

981 with real_moderation_session(super_token) as api: 

982 res = api.GetModerationQueue( 

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

984 ) 

985 assert len(res.queue_items) == 2 

986 

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

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

989 with real_moderation_session(super_token) as api: 

990 res = api.GetModerationQueue( 

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

992 ) 

993 assert len(res.queue_items) == 0 

994 

995 

996def test_GetModerationQueue_filter_created_after(db): 

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

998 super_user, super_token = generate_user(is_superuser=True) 

999 normal_user, user_token = generate_user() 

1000 host, _ = generate_user() 

1001 

1002 # Create host requests 

1003 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1004 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1005 

1006 # Backdate the first queue item to 2 hours ago 

1007 with session_scope() as session: 

1008 queue_item1 = session.execute( 

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

1010 ).scalar_one() 

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

1012 

1013 # The second item remains at current time 

1014 

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

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

1017 with real_moderation_session(super_token) as api: 

1018 res = api.GetModerationQueue( 

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

1020 ) 

1021 assert len(res.queue_items) == 1 

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

1023 

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

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

1026 with real_moderation_session(super_token) as api: 

1027 res = api.GetModerationQueue( 

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

1029 ) 

1030 assert len(res.queue_items) == 2 

1031 

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

1033 with real_moderation_session(super_token) as api: 

1034 res = api.GetModerationQueue( 

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

1036 ) 

1037 assert len(res.queue_items) == 0 

1038 

1039 

1040def test_GetModerationQueue_filter_created_before_and_after(db): 

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

1042 super_user, super_token = generate_user(is_superuser=True) 

1043 normal_user, user_token = generate_user() 

1044 host, _ = generate_user() 

1045 

1046 # Create 3 host requests 

1047 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1048 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1049 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1050 

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

1052 with session_scope() as session: 

1053 queue_item1 = session.execute( 

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

1055 ).scalar_one() 

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

1057 

1058 queue_item2 = session.execute( 

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

1060 ).scalar_one() 

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

1062 

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

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

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

1066 with real_moderation_session(super_token) as api: 

1067 res = api.GetModerationQueue( 

1068 moderation_pb2.GetModerationQueueReq( 

1069 created_after=Timestamp_from_datetime(after_cutoff), 

1070 created_before=Timestamp_from_datetime(before_cutoff), 

1071 ) 

1072 ) 

1073 assert len(res.queue_items) == 1 

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

1075 

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

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

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

1079 with real_moderation_session(super_token) as api: 

1080 res = api.GetModerationQueue( 

1081 moderation_pb2.GetModerationQueueReq( 

1082 created_after=Timestamp_from_datetime(after_cutoff), 

1083 created_before=Timestamp_from_datetime(before_cutoff), 

1084 ) 

1085 ) 

1086 assert len(res.queue_items) == 1 

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

1088 

1089 

1090def test_GetModerationQueue_filter_unresolved(db): 

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

1092 super_user, super_token = generate_user(is_superuser=True) 

1093 normal_user, user_token = generate_user() 

1094 host, _ = generate_user() 

1095 

1096 # Create 2 host requests 

1097 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1098 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1099 

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

1101 with real_moderation_session(super_token) as api: 

1102 api.ModerateContent( 

1103 moderation_pb2.ModerateContentReq( 

1104 moderation_state_id=state1_id, 

1105 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1106 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1107 reason="Approved", 

1108 ) 

1109 ) 

1110 

1111 # Get all items 

1112 with real_moderation_session(super_token) as api: 

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

1114 assert len(res.queue_items) == 2 

1115 

1116 # Get only unresolved items 

1117 with real_moderation_session(super_token) as api: 

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

1119 assert len(res.queue_items) == 1 

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

1121 

1122 

1123def test_GetModerationQueue_filter_by_author(db): 

1124 """Test filtering moderation queue by item_author_user_id""" 

1125 super_user, super_token = generate_user(is_superuser=True) 

1126 user1, token1 = generate_user() 

1127 user2, token2 = generate_user() 

1128 host_user, _ = generate_user() 

1129 

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

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

1132 

1133 # Create 2 host requests by user1 

1134 with requests_session(token1) as api: 

1135 hr1_id = api.CreateHostRequest( 

1136 requests_pb2.CreateHostRequestReq( 

1137 host_user_id=host_user.id, 

1138 from_date=today_plus_2, 

1139 to_date=today_plus_3, 

1140 text=valid_request_text(), 

1141 ) 

1142 ).host_request_id 

1143 

1144 hr2_id = api.CreateHostRequest( 

1145 requests_pb2.CreateHostRequestReq( 

1146 host_user_id=host_user.id, 

1147 from_date=today_plus_2, 

1148 to_date=today_plus_3, 

1149 text=valid_request_text(), 

1150 ) 

1151 ).host_request_id 

1152 

1153 # Create 1 host request by user2 

1154 with requests_session(token2) as api: 

1155 hr3_id = api.CreateHostRequest( 

1156 requests_pb2.CreateHostRequestReq( 

1157 host_user_id=host_user.id, 

1158 from_date=today_plus_2, 

1159 to_date=today_plus_3, 

1160 text=valid_request_text(), 

1161 ) 

1162 ).host_request_id 

1163 

1164 # Get moderation state IDs 

1165 state1_id, state2_id, state3_id = None, None, None 

1166 with session_scope() as session: 

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

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

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

1170 state1_id = hr1.moderation_state_id 

1171 state2_id = hr2.moderation_state_id 

1172 state3_id = hr3.moderation_state_id 

1173 

1174 # Get all items (should be 3) 

1175 with real_moderation_session(super_token) as api: 

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

1177 assert len(res.queue_items) == 3 

1178 

1179 # Filter by user1 (should get 2) 

1180 with real_moderation_session(super_token) as api: 

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

1182 assert len(res.queue_items) == 2 

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

1184 

1185 # Filter by user2 (should get 1) 

1186 with real_moderation_session(super_token) as api: 

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

1188 assert len(res.queue_items) == 1 

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

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

1191 

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

1193 with real_moderation_session(super_token) as api: 

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

1195 assert len(res.queue_items) == 0 

1196 

1197 

1198def test_GetModerationQueue_ordering(db): 

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

1200 super_user, super_token = generate_user(is_superuser=True) 

1201 normal_user, user_token = generate_user() 

1202 host, _ = generate_user() 

1203 

1204 # Create 3 host requests 

1205 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1206 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1207 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1208 

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

1210 with session_scope() as session: 

1211 queue_item1 = session.execute( 

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

1213 ).scalar_one() 

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

1215 

1216 queue_item2 = session.execute( 

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

1218 ).scalar_one() 

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

1220 

1221 queue_item3 = session.execute( 

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

1223 ).scalar_one() 

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

1225 

1226 # Default order (oldest first) 

1227 with real_moderation_session(super_token) as api: 

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

1229 assert len(res.queue_items) == 3 

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

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

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

1233 

1234 # Explicit oldest first 

1235 with real_moderation_session(super_token) as api: 

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

1237 assert len(res.queue_items) == 3 

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

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

1240 

1241 # Newest first 

1242 with real_moderation_session(super_token) as api: 

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

1244 assert len(res.queue_items) == 3 

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

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

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

1248 

1249 

1250def test_GetModerationQueue_pagination_newest_first(db): 

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

1252 super_user, super_token = generate_user(is_superuser=True) 

1253 normal_user, normal_token = generate_user() 

1254 host_user, _ = generate_user() 

1255 

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

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

1258 

1259 # Create 5 host requests 

1260 hr_ids = [] 

1261 with requests_session(normal_token) as api: 

1262 for i in range(5): 

1263 hr_id = api.CreateHostRequest( 

1264 requests_pb2.CreateHostRequestReq( 

1265 host_user_id=host_user.id, 

1266 from_date=today_plus_2, 

1267 to_date=today_plus_3, 

1268 text=valid_request_text(), 

1269 ) 

1270 ).host_request_id 

1271 hr_ids.append(hr_id) 

1272 

1273 # Get moderation state IDs 

1274 state_ids = [] 

1275 with session_scope() as session: 

1276 for hr_id in hr_ids: 

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

1278 state_ids.append(hr.moderation_state_id) 

1279 

1280 # Set different times so ordering is deterministic 

1281 with session_scope() as session: 

1282 for i, state_id in enumerate(state_ids): 

1283 queue_item = session.execute( 

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

1285 ).scalar_one() 

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

1287 

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

1289 with real_moderation_session(super_token) as api: 

1290 res1 = api.GetModerationQueue( 

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

1292 ) 

1293 assert len(res1.queue_items) == 2 

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

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

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

1297 assert res1.next_page_token # should have more pages 

1298 

1299 # Get second page using the token 

1300 res2 = api.GetModerationQueue( 

1301 moderation_pb2.GetModerationQueueReq( 

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

1303 ) 

1304 ) 

1305 assert len(res2.queue_items) == 2 

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

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

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

1309 

1310 # Pages should not overlap 

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

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

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

1314 

1315 

1316def test_GetModerationLog(db): 

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

1318 super_user, super_token = generate_user(is_superuser=True) 

1319 moderator, moderator_token = generate_user(is_superuser=True) 

1320 normal_user, user_token = generate_user() 

1321 host, _ = generate_user() 

1322 

1323 # Create a real host request 

1324 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1325 

1326 # Perform a moderation action via API 

1327 with real_moderation_session(moderator_token) as api: 

1328 api.ModerateContent( 

1329 moderation_pb2.ModerateContentReq( 

1330 moderation_state_id=state_id, 

1331 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1332 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1333 reason="Looks good", 

1334 ) 

1335 ) 

1336 

1337 with real_moderation_session(super_token) as api: 

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

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

1340 assert res.moderation_state.moderation_state_id == state_id 

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

1342 # Log entries are in reverse chronological order 

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

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

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

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

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

1348 

1349 

1350def test_GetModerationLog_not_found(db): 

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

1352 super_user, super_token = generate_user(is_superuser=True) 

1353 

1354 with real_moderation_session(super_token) as api: 

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

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

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

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

1359 

1360 

1361def test_GetModerationState(db): 

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

1363 super_user, super_token = generate_user(is_superuser=True) 

1364 user1, token1 = generate_user() 

1365 user2, _ = generate_user() 

1366 

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

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

1369 

1370 with requests_session(token1) as api: 

1371 host_request_id = api.CreateHostRequest( 

1372 requests_pb2.CreateHostRequestReq( 

1373 host_user_id=user2.id, 

1374 from_date=today_plus_2, 

1375 to_date=today_plus_3, 

1376 text=valid_request_text(), 

1377 ) 

1378 ).host_request_id 

1379 

1380 with real_moderation_session(super_token) as api: 

1381 res = api.GetModerationState( 

1382 moderation_pb2.GetModerationStateReq( 

1383 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1384 object_id=host_request_id, 

1385 ) 

1386 ) 

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

1388 assert res.moderation_state.object_id == host_request_id 

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

1390 assert res.moderation_state.moderation_state_id > 0 

1391 

1392 

1393def test_GetModerationState_not_found(db): 

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

1395 super_user, super_token = generate_user(is_superuser=True) 

1396 

1397 with real_moderation_session(super_token) as api: 

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

1399 api.GetModerationState( 

1400 moderation_pb2.GetModerationStateReq( 

1401 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1402 object_id=999999, 

1403 ) 

1404 ) 

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

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

1407 

1408 

1409def test_GetModerationState_unspecified_type(db): 

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

1411 super_user, super_token = generate_user(is_superuser=True) 

1412 

1413 with real_moderation_session(super_token) as api: 

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

1415 api.GetModerationState( 

1416 moderation_pb2.GetModerationStateReq( 

1417 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED, 

1418 object_id=123, 

1419 ) 

1420 ) 

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

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

1423 

1424 

1425def test_ModerateContent_approve(db): 

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

1427 super_user, super_token = generate_user(is_superuser=True) 

1428 user1, token1 = generate_user() 

1429 user2, _ = generate_user() 

1430 

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

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

1433 

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

1435 with requests_session(token1) as api: 

1436 host_request_id = api.CreateHostRequest( 

1437 requests_pb2.CreateHostRequestReq( 

1438 host_user_id=user2.id, 

1439 from_date=today_plus_2, 

1440 to_date=today_plus_3, 

1441 text=valid_request_text(), 

1442 ) 

1443 ).host_request_id 

1444 

1445 # Get the moderation state ID 

1446 state_id = None 

1447 with session_scope() as session: 

1448 host_request = session.execute( 

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

1450 ).scalar_one() 

1451 state_id = host_request.moderation_state_id 

1452 

1453 with real_moderation_session(super_token) as api: 

1454 res = api.ModerateContent( 

1455 moderation_pb2.ModerateContentReq( 

1456 moderation_state_id=state_id, 

1457 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1458 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1459 reason="Approved by admin", 

1460 ) 

1461 ) 

1462 assert res.moderation_state.moderation_state_id == state_id 

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

1464 

1465 # Verify state was updated in database 

1466 with session_scope() as session: 

1467 state = session.get_one(ModerationState, state_id) 

1468 assert state.visibility == ModerationVisibility.visible 

1469 

1470 

1471def test_ModerateContent_not_found(db): 

1472 """Test moderating non-existent content""" 

1473 super_user, super_token = generate_user(is_superuser=True) 

1474 

1475 with real_moderation_session(super_token) as api: 

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

1477 api.ModerateContent( 

1478 moderation_pb2.ModerateContentReq( 

1479 moderation_state_id=999999, 

1480 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1481 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1482 reason="Test", 

1483 ) 

1484 ) 

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

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

1487 

1488 

1489def test_ModerateContent_hide(db): 

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

1491 super_user, super_token = generate_user(is_superuser=True) 

1492 normal_user, user_token = generate_user() 

1493 host, _ = generate_user() 

1494 

1495 # Create a real host request 

1496 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1497 

1498 with real_moderation_session(super_token) as api: 

1499 res = api.ModerateContent( 

1500 moderation_pb2.ModerateContentReq( 

1501 moderation_state_id=state_id, 

1502 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1503 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

1504 reason="Spam content", 

1505 ) 

1506 ) 

1507 assert res.moderation_state.moderation_state_id == state_id 

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

1509 

1510 # Verify state was updated in database 

1511 with session_scope() as session: 

1512 state = session.get_one(ModerationState, state_id) 

1513 assert state.visibility == ModerationVisibility.hidden 

1514 

1515 

1516def test_ModerateContent_shadow(db): 

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

1518 super_user, super_token = generate_user(is_superuser=True) 

1519 normal_user, user_token = generate_user() 

1520 host, _ = generate_user() 

1521 

1522 # Create a real host request 

1523 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1524 

1525 with real_moderation_session(super_token) as api: 

1526 res = api.ModerateContent( 

1527 moderation_pb2.ModerateContentReq( 

1528 moderation_state_id=state_id, 

1529 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1530 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

1531 reason="Needs further review", 

1532 ) 

1533 ) 

1534 assert res.moderation_state.moderation_state_id == state_id 

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

1536 

1537 # Verify state was updated in database 

1538 with session_scope() as session: 

1539 state = session.get_one(ModerationState, state_id) 

1540 assert state.visibility == ModerationVisibility.shadowed 

1541 

1542 

1543def test_FlagContentForReview(db): 

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

1545 super_user, super_token = generate_user(is_superuser=True) 

1546 user1, token1 = generate_user() 

1547 user2, _ = generate_user() 

1548 

1549 # Create a host request 

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

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

1552 

1553 with requests_session(token1) as api: 

1554 host_request_id = api.CreateHostRequest( 

1555 requests_pb2.CreateHostRequestReq( 

1556 host_user_id=user2.id, 

1557 from_date=today_plus_2, 

1558 to_date=today_plus_3, 

1559 text=valid_request_text(), 

1560 ) 

1561 ).host_request_id 

1562 

1563 # Get the moderation state ID 

1564 with session_scope() as session: 

1565 host_request = session.execute( 

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

1567 ).scalar_one() 

1568 state_id = host_request.moderation_state_id 

1569 

1570 with real_moderation_session(super_token) as api: 

1571 res = api.FlagContentForReview( 

1572 moderation_pb2.FlagContentForReviewReq( 

1573 moderation_state_id=state_id, 

1574 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

1575 reason="Admin flagged for additional review", 

1576 ) 

1577 ) 

1578 assert res.queue_item.moderation_state_id == state_id 

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

1580 assert res.queue_item.is_resolved == False 

1581 

1582 # Verify queue item was created in database 

1583 with session_scope() as session: 

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

1585 queue_item = ( 

1586 session.execute( 

1587 select(ModerationQueueItem) 

1588 .where(ModerationQueueItem.moderation_state_id == state_id) 

1589 .order_by(ModerationQueueItem.time_created.desc()) 

1590 ) 

1591 .scalars() 

1592 .first() 

1593 ) 

1594 assert queue_item 

1595 assert queue_item.trigger == ModerationTrigger.moderator_review 

1596 assert queue_item.resolved_by_log_id is None 

1597 

1598 

1599# ============================================================================ 

1600# Tests for group chat moderation 

1601# ============================================================================ 

1602 

1603 

1604def test_group_chat_created_with_moderation_state(db): 

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

1606 user1, token1 = generate_user() 

1607 user2, _ = generate_user() 

1608 make_friends(user1, user2) 

1609 

1610 with conversations_session(token1) as api: 

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

1612 group_chat_id = res.group_chat_id 

1613 

1614 # Verify moderation state was created 

1615 with session_scope() as session: 

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

1617 

1618 assert group_chat.moderation_state.object_type == ModerationObjectType.group_chat 

1619 assert group_chat.moderation_state.object_id == group_chat_id 

1620 # Group chats start as SHADOWED 

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

1622 

1623 # A moderation queue item should have been created 

1624 queue_item = ( 

1625 session.execute( 

1626 select(ModerationQueueItem).where( 

1627 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id 

1628 ) 

1629 ) 

1630 .scalars() 

1631 .first() 

1632 ) 

1633 assert queue_item is not None 

1634 assert queue_item.trigger == ModerationTrigger.initial_review 

1635 

1636 

1637def test_group_chat_GetModerationState(db): 

1638 """Test GetModerationState API for group chats""" 

1639 user1, token1 = generate_user() 

1640 user2, _ = generate_user() 

1641 moderator, mod_token = generate_user(is_superuser=True) 

1642 make_friends(user1, user2) 

1643 

1644 with conversations_session(token1) as api: 

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

1646 group_chat_id = res.group_chat_id 

1647 

1648 # Moderator can look up the moderation state 

1649 with real_moderation_session(mod_token) as api: 

1650 res = api.GetModerationState( 

1651 moderation_pb2.GetModerationStateReq( 

1652 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1653 object_id=group_chat_id, 

1654 ) 

1655 ) 

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

1657 assert res.moderation_state.object_id == group_chat_id 

1658 # Starts as SHADOWED 

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

1660 

1661 

1662def test_group_chat_moderation_hide(db): 

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

1664 user1, token1 = generate_user() 

1665 user2, token2 = generate_user() 

1666 moderator, mod_token = generate_user(is_superuser=True) 

1667 make_friends(user1, user2) 

1668 

1669 with conversations_session(token1) as api: 

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

1671 group_chat_id = res.group_chat_id 

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

1673 

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

1675 with real_moderation_session(mod_token) as api: 

1676 state_res = api.GetModerationState( 

1677 moderation_pb2.GetModerationStateReq( 

1678 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1679 object_id=group_chat_id, 

1680 ) 

1681 ) 

1682 api.ModerateContent( 

1683 moderation_pb2.ModerateContentReq( 

1684 moderation_state_id=state_res.moderation_state.moderation_state_id, 

1685 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1686 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1687 reason="Approved", 

1688 ) 

1689 ) 

1690 

1691 # Both users can see the chat now 

1692 with conversations_session(token1) as api: 

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

1694 assert len(res.group_chats) == 1 

1695 

1696 with conversations_session(token2) as api: 

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

1698 assert len(res.group_chats) == 1 

1699 

1700 # Moderator hides the group chat 

1701 with real_moderation_session(mod_token) as api: 

1702 state_res = api.GetModerationState( 

1703 moderation_pb2.GetModerationStateReq( 

1704 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1705 object_id=group_chat_id, 

1706 ) 

1707 ) 

1708 api.ModerateContent( 

1709 moderation_pb2.ModerateContentReq( 

1710 moderation_state_id=state_res.moderation_state.moderation_state_id, 

1711 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1712 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

1713 reason="Inappropriate content", 

1714 ) 

1715 ) 

1716 

1717 # Neither user can see the chat now 

1718 with conversations_session(token1) as api: 

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

1720 assert len(res.group_chats) == 0 

1721 

1722 with conversations_session(token2) as api: 

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

1724 assert len(res.group_chats) == 0 

1725 

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

1727 with conversations_session(token1) as api: 

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

1729 assert len(res.messages) == 0 

1730 

1731 

1732def test_group_chat_moderation_shadow(db): 

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

1734 user1, token1 = generate_user() # Creator 

1735 user2, token2 = generate_user() # Participant 

1736 moderator, mod_token = generate_user(is_superuser=True) 

1737 make_friends(user1, user2) 

1738 

1739 with conversations_session(token1) as api: 

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

1741 group_chat_id = res.group_chat_id 

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

1743 

1744 # Moderator shadows the group chat 

1745 with real_moderation_session(mod_token) as api: 

1746 state_res = api.GetModerationState( 

1747 moderation_pb2.GetModerationStateReq( 

1748 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1749 object_id=group_chat_id, 

1750 ) 

1751 ) 

1752 api.ModerateContent( 

1753 moderation_pb2.ModerateContentReq( 

1754 moderation_state_id=state_res.moderation_state.moderation_state_id, 

1755 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1756 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

1757 reason="Needs review", 

1758 ) 

1759 ) 

1760 

1761 # Creator can see SHADOWED content in list operations 

1762 with conversations_session(token1) as api: 

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

1764 assert len(res.group_chats) == 1 

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

1766 

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

1768 with conversations_session(token2) as api: 

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

1770 assert len(res.group_chats) == 0 

1771 

1772 # Creator can also access it directly via GetGroupChat 

1773 with conversations_session(token1) as api: 

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

1775 assert res.group_chat_id == group_chat_id 

1776 

1777 

1778# ============================================================================ 

1779# Tests for auto-approval background job 

1780# ============================================================================ 

1781 

1782 

1783def test_auto_approve_moderation_queue_disabled_when_zero(db): 

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

1785 moderator, mod_token = generate_user(is_superuser=True) 

1786 user1, token1 = generate_user() 

1787 user2, token2 = generate_user() 

1788 

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

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

1791 

1792 # Create a host request 

1793 with requests_session(token1) as api: 

1794 with mock_notification_email() as mock: 

1795 host_request_id = api.CreateHostRequest( 

1796 requests_pb2.CreateHostRequestReq( 

1797 host_user_id=user2.id, 

1798 from_date=today_plus_2, 

1799 to_date=today_plus_3, 

1800 text=valid_request_text(), 

1801 ) 

1802 ).host_request_id 

1803 

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

1805 mock.assert_not_called() 

1806 

1807 # Ensure deadline is 0 (disabled) 

1808 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 0 

1809 

1810 # Run the job 

1811 auto_approve_moderation_queue(empty_pb2.Empty()) 

1812 

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

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

1815 assert res.host_request_id == host_request_id 

1816 

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

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

1819 assert len(res.host_requests) == 1 

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

1821 

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

1823 with requests_session(token2) as api: 

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

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

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

1827 

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

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

1830 assert len(res.host_requests) == 0 

1831 

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

1833 with real_moderation_session(mod_token) as api: 

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

1835 assert len(res.queue_items) == 1 

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

1837 

1838 # Moderator can check the state is still SHADOWED 

1839 state_res = api.GetModerationState( 

1840 moderation_pb2.GetModerationStateReq( 

1841 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1842 object_id=host_request_id, 

1843 ) 

1844 ) 

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

1846 

1847 

1848def test_auto_approve_moderation_queue_approves_old_items(db, push_collector: PushCollector): 

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

1850 moderator, mod_token = generate_user(is_superuser=True) 

1851 user1, token1 = generate_user() 

1852 user2, token2 = generate_user() 

1853 

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

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

1856 

1857 # Create a host request 

1858 with requests_session(token1) as api: 

1859 with mock_notification_email() as mock: 

1860 host_request_id = api.CreateHostRequest( 

1861 requests_pb2.CreateHostRequestReq( 

1862 host_user_id=user2.id, 

1863 from_date=today_plus_2, 

1864 to_date=today_plus_3, 

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

1866 ) 

1867 ).host_request_id 

1868 

1869 # No email sent initially (shadowed) 

1870 mock.assert_not_called() 

1871 

1872 # Host cannot see the request yet 

1873 with requests_session(token2) as api: 

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

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

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

1877 

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

1879 with session_scope() as session: 

1880 host_request = session.execute( 

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

1882 ).scalar_one() 

1883 queue_item = session.execute( 

1884 select(ModerationQueueItem) 

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

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

1887 ).scalar_one() 

1888 # Backdate the queue item by 2 minutes 

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

1890 

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

1892 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 60 

1893 config["MODERATION_BOT_USER_ID"] = moderator.id 

1894 

1895 # Run the job 

1896 auto_approve_moderation_queue(empty_pb2.Empty()) 

1897 

1898 # Now host can see the request via API 

1899 with requests_session(token2) as api: 

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

1901 assert res.host_request_id == host_request_id 

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

1903 

1904 # Host sees it in their received list 

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

1906 assert len(res.host_requests) == 1 

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

1908 

1909 # Surfer sees it in their sent list 

1910 with requests_session(token1) as api: 

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

1912 assert len(res.host_requests) == 1 

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

1914 

1915 # Moderator sees the queue item is now resolved 

1916 with real_moderation_session(mod_token) as api: 

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

1918 assert len(res.queue_items) == 0 

1919 

1920 # State is now VISIBLE 

1921 state_res = api.GetModerationState( 

1922 moderation_pb2.GetModerationStateReq( 

1923 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1924 object_id=host_request_id, 

1925 ) 

1926 ) 

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

1928 

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

1930 log_res = api.GetModerationLog( 

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

1932 ) 

1933 # Find the APPROVE action 

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

1935 assert len(approve_entries) == 1 

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

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

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

1939 

1940 

1941def test_auto_approve_does_not_approve_recent_items(db): 

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

1943 moderator, mod_token = generate_user(is_superuser=True) 

1944 user1, token1 = generate_user() 

1945 user2, token2 = generate_user() 

1946 

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

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

1949 

1950 # Create a host request 

1951 with requests_session(token1) as api: 

1952 with mock_notification_email() as mock: 

1953 host_request_id = api.CreateHostRequest( 

1954 requests_pb2.CreateHostRequestReq( 

1955 host_user_id=user2.id, 

1956 from_date=today_plus_2, 

1957 to_date=today_plus_3, 

1958 text=valid_request_text(), 

1959 ) 

1960 ).host_request_id 

1961 

1962 # No email sent (shadowed) 

1963 mock.assert_not_called() 

1964 

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

1966 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 3600 

1967 config["MODERATION_BOT_USER_ID"] = moderator.id 

1968 

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

1970 with mock_notification_email() as mock: 

1971 auto_approve_moderation_queue(empty_pb2.Empty()) 

1972 

1973 # Still no email sent 

1974 mock.assert_not_called() 

1975 

1976 # Host still cannot see the request 

1977 with requests_session(token2) as api: 

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

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

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

1981 

1982 # Not in host's received list 

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

1984 assert len(res.host_requests) == 0 

1985 

1986 # Moderator sees it still in queue unresolved 

1987 with real_moderation_session(mod_token) as api: 

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

1989 assert len(res.queue_items) == 1 

1990 

1991 # State is still SHADOWED 

1992 state_res = api.GetModerationState( 

1993 moderation_pb2.GetModerationStateReq( 

1994 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1995 object_id=host_request_id, 

1996 ) 

1997 ) 

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

1999 

2000 

2001def test_auto_approve_does_not_approve_already_approved(db): 

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

2003 moderator, mod_token = generate_user(is_superuser=True) 

2004 user1, token1 = generate_user() 

2005 user2, token2 = generate_user() 

2006 

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

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

2009 

2010 # Create a host request 

2011 with requests_session(token1) as api: 

2012 host_request_id = api.CreateHostRequest( 

2013 requests_pb2.CreateHostRequestReq( 

2014 host_user_id=user2.id, 

2015 from_date=today_plus_2, 

2016 to_date=today_plus_3, 

2017 text=valid_request_text(), 

2018 ) 

2019 ).host_request_id 

2020 

2021 # Moderator approves it manually 

2022 with real_moderation_session(mod_token) as api: 

2023 state_res = api.GetModerationState( 

2024 moderation_pb2.GetModerationStateReq( 

2025 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2026 object_id=host_request_id, 

2027 ) 

2028 ) 

2029 state_id = state_res.moderation_state.moderation_state_id 

2030 

2031 api.ModerateContent( 

2032 moderation_pb2.ModerateContentReq( 

2033 moderation_state_id=state_id, 

2034 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

2035 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

2036 reason="Approved by moderator", 

2037 ) 

2038 ) 

2039 

2040 # Host can now see it 

2041 with requests_session(token2) as api: 

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

2043 assert res.host_request_id == host_request_id 

2044 

2045 # Get log count before auto-approval 

2046 with real_moderation_session(mod_token) as api: 

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

2048 log_count_before = len(log_res_before.log_entries) 

2049 

2050 # Set deadline to 1 second 

2051 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1 

2052 config["MODERATION_BOT_USER_ID"] = moderator.id 

2053 

2054 # Run the job 

2055 auto_approve_moderation_queue(empty_pb2.Empty()) 

2056 

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

2058 with real_moderation_session(mod_token) as api: 

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

2060 assert len(log_res_after.log_entries) == log_count_before 

2061 

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

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

2064 assert len(queue_res.queue_items) == 0 

2065 

2066 

2067def test_auto_approve_does_not_approve_moderator_shadowed_items(db): 

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

2069 moderator, mod_token = generate_user(is_superuser=True) 

2070 user1, token1 = generate_user() 

2071 user2, token2 = generate_user() 

2072 

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

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

2075 

2076 # Create a host request 

2077 with requests_session(token1) as api: 

2078 host_request_id = api.CreateHostRequest( 

2079 requests_pb2.CreateHostRequestReq( 

2080 host_user_id=user2.id, 

2081 from_date=today_plus_2, 

2082 to_date=today_plus_3, 

2083 text=valid_request_text(), 

2084 ) 

2085 ).host_request_id 

2086 

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

2088 with real_moderation_session(mod_token) as api: 

2089 state_res = api.GetModerationState( 

2090 moderation_pb2.GetModerationStateReq( 

2091 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2092 object_id=host_request_id, 

2093 ) 

2094 ) 

2095 state_id = state_res.moderation_state.moderation_state_id 

2096 

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

2098 api.ModerateContent( 

2099 moderation_pb2.ModerateContentReq( 

2100 moderation_state_id=state_id, 

2101 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2102 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

2103 reason="Keeping shadowed for review", 

2104 ) 

2105 ) 

2106 

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

2108 with session_scope() as session: 

2109 queue_item = session.execute( 

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

2111 ).scalar_one() 

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

2113 

2114 # Set deadline to 1 second 

2115 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1 

2116 config["MODERATION_BOT_USER_ID"] = moderator.id 

2117 

2118 # Get log count before 

2119 with real_moderation_session(mod_token) as api: 

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

2121 log_count_before = len(log_res_before.log_entries) 

2122 

2123 # Run the job 

2124 auto_approve_moderation_queue(empty_pb2.Empty()) 

2125 

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

2127 with real_moderation_session(mod_token) as api: 

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

2129 assert len(log_res_after.log_entries) == log_count_before 

2130 

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

2132 state_res = api.GetModerationState( 

2133 moderation_pb2.GetModerationStateReq( 

2134 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2135 object_id=host_request_id, 

2136 ) 

2137 ) 

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

2139 

2140 # Host still cannot see the request 

2141 with requests_session(token2) as api: 

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

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

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

2145 

2146 

2147# ============================================================================ 

2148# Notification Suppression Tests 

2149# ============================================================================ 

2150 

2151 

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

2153 """ 

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

2155 that haven't been approved yet. 

2156 """ 

2157 host, host_token = generate_user(complete_profile=True) 

2158 surfer, surfer_token = generate_user(complete_profile=True) 

2159 

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

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

2162 

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

2164 with requests_session(surfer_token) as api: 

2165 hr_id = api.CreateHostRequest( 

2166 requests_pb2.CreateHostRequestReq( 

2167 host_user_id=host.id, 

2168 from_date=today_plus_2, 

2169 to_date=today_plus_3, 

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

2171 ) 

2172 ).host_request_id 

2173 

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

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

2176 

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

2178 with requests_session(surfer_token) as api: 

2179 api.SendHostRequestMessage( 

2180 requests_pb2.SendHostRequestMessageReq( 

2181 host_request_id=hr_id, 

2182 text="Follow-up message 1", 

2183 ) 

2184 ) 

2185 api.SendHostRequestMessage( 

2186 requests_pb2.SendHostRequestMessageReq( 

2187 host_request_id=hr_id, 

2188 text="Follow-up message 2", 

2189 ) 

2190 ) 

2191 

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

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

2194 

2195 # Now approve the request 

2196 with mock_notification_email(): 

2197 moderator.approve_host_request(hr_id) 

2198 

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

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

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

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

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

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

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

2206 

2207 

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

2209 """ 

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

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

2212 

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

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

2215 """ 

2216 host, host_token = generate_user(complete_profile=True) 

2217 surfer, surfer_token = generate_user(complete_profile=True) 

2218 

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

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

2221 

2222 # Create host request 

2223 with requests_session(surfer_token) as api: 

2224 hr_id = api.CreateHostRequest( 

2225 requests_pb2.CreateHostRequestReq( 

2226 host_user_id=host.id, 

2227 from_date=today_plus_2, 

2228 to_date=today_plus_3, 

2229 text=valid_request_text(), 

2230 ) 

2231 ).host_request_id 

2232 

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

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

2235 

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

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

2238 with requests_session(surfer_token) as api: 

2239 api.RespondHostRequest( 

2240 requests_pb2.RespondHostRequestReq( 

2241 host_request_id=hr_id, 

2242 status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED, 

2243 text="Actually, never mind", 

2244 ) 

2245 ) 

2246 

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

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

2249 

2250 

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

2252 """ 

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

2254 """ 

2255 host, host_token = generate_user(complete_profile=True) 

2256 surfer, surfer_token = generate_user(complete_profile=True) 

2257 

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

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

2260 

2261 # Create and approve host request 

2262 with requests_session(surfer_token) as api: 

2263 hr_id = api.CreateHostRequest( 

2264 requests_pb2.CreateHostRequestReq( 

2265 host_user_id=host.id, 

2266 from_date=today_plus_2, 

2267 to_date=today_plus_3, 

2268 text=valid_request_text(), 

2269 ) 

2270 ).host_request_id 

2271 

2272 with mock_notification_email(): 

2273 moderator.approve_host_request(hr_id) 

2274 

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

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

2277 

2278 # Host accepts the request - surfer should be notified 

2279 with requests_session(host_token) as api: 

2280 with mock_notification_email(): 

2281 api.RespondHostRequest( 

2282 requests_pb2.RespondHostRequestReq( 

2283 host_request_id=hr_id, 

2284 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

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

2286 ) 

2287 ) 

2288 

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

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

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

2292 

2293 # Surfer confirms - host should be notified 

2294 with requests_session(surfer_token) as api: 

2295 with mock_notification_email(): 

2296 api.RespondHostRequest( 

2297 requests_pb2.RespondHostRequestReq( 

2298 host_request_id=hr_id, 

2299 status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED, 

2300 text="See you then!", 

2301 ) 

2302 ) 

2303 

2304 # Host should now have received the confirmation notifications 

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

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

2307 

2308 

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

2310 """ 

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

2312 that haven't been approved yet. 

2313 """ 

2314 from couchers.jobs.worker import process_job 

2315 from couchers.models import GroupChat 

2316 

2317 user1, token1 = generate_user(complete_profile=True) 

2318 user2, token2 = generate_user(complete_profile=True) 

2319 

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

2321 with conversations_session(token1) as api: 

2322 res = api.CreateGroupChat( 

2323 conversations_pb2.CreateGroupChatReq( 

2324 recipient_user_ids=[user2.id], 

2325 ) 

2326 ) 

2327 gc_id = res.group_chat_id 

2328 

2329 # Verify initial state 

2330 with session_scope() as session: 

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

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

2333 

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

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

2336 

2337 # Send messages BEFORE approval 

2338 with conversations_session(token1) as api: 

2339 api.SendMessage( 

2340 conversations_pb2.SendMessageReq( 

2341 group_chat_id=gc_id, 

2342 text="Hello before approval", 

2343 ) 

2344 ) 

2345 

2346 # Process the queued notification job 

2347 while process_job(): 

2348 pass 

2349 

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

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

2352 

2353 # Now approve the group chat 

2354 moderator.approve_group_chat(gc_id) 

2355 

2356 # Process the queued notification jobs from approval 

2357 while process_job(): 

2358 pass 

2359 

2360 # Verify moderation state after approval 

2361 with session_scope() as session: 

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

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

2364 

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

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

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

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

2369 

2370 # Send a message AFTER approval 

2371 with conversations_session(token1) as api: 

2372 api.SendMessage( 

2373 conversations_pb2.SendMessageReq( 

2374 group_chat_id=gc_id, 

2375 text="Hello after approval", 

2376 ) 

2377 ) 

2378 

2379 # Process the queued notification job 

2380 while process_job(): 

2381 pass 

2382 

2383 # User2 should have received another notification 

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

2385 

2386 

2387def test_event_moderation_state_content(db): 

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

2389 super_user, super_token = generate_user(is_superuser=True) 

2390 user, token = generate_user() 

2391 

2392 with session_scope() as session: 

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

2394 

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

2396 end_time = start_time + timedelta(hours=3) 

2397 

2398 with events_session(token) as api: 

2399 res = api.CreateEvent( 

2400 events_pb2.CreateEventReq( 

2401 title="My Event Title", 

2402 content="My event description.", 

2403 photo_key=None, 

2404 offline_information=events_pb2.OfflineEventInformation( 

2405 address="Near Null Island", 

2406 lat=0.1, 

2407 lng=0.2, 

2408 ), 

2409 start_time=Timestamp_from_datetime(start_time), 

2410 end_time=Timestamp_from_datetime(end_time), 

2411 timezone="UTC", 

2412 ) 

2413 ) 

2414 event_id = res.event_id 

2415 

2416 with session_scope() as session: 

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

2418 state_id = occurrence.moderation_state_id 

2419 

2420 with real_moderation_session(super_token) as api: 

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

2422 event_items = [ 

2423 item 

2424 for item in res.queue_items 

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

2426 ] 

2427 assert len(event_items) == 1 

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