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

992 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-03 06:18 +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 GroupChat, 

17 HostRequest, 

18 ModerationAction, 

19 ModerationLog, 

20 ModerationObjectType, 

21 ModerationQueueItem, 

22 ModerationState, 

23 ModerationTrigger, 

24 ModerationVisibility, 

25) 

26from couchers.moderation.utils import create_moderation 

27from couchers.proto import conversations_pb2, moderation_pb2, notifications_pb2, requests_pb2 

28from couchers.utils import Timestamp_from_datetime, now, today 

29from tests.fixtures.db import generate_user, make_friends 

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

31from tests.fixtures.sessions import ( 

32 conversations_session, 

33 notifications_session, 

34 real_moderation_session, 

35 requests_session, 

36) 

37from tests.test_requests import valid_request_text 

38 

39 

40@pytest.fixture(autouse=True) 

41def _(testconfig): 

42 pass 

43 

44 

45def create_test_host_request_with_moderation(surfer_token, host_user_id): 

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

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

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

49 

50 with requests_session(surfer_token) as api: 

51 hr_id = api.CreateHostRequest( 

52 requests_pb2.CreateHostRequestReq( 

53 host_user_id=host_user_id, 

54 from_date=today_plus_2, 

55 to_date=today_plus_3, 

56 text=valid_request_text(), 

57 ) 

58 ).host_request_id 

59 

60 with session_scope() as session: 

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

62 return hr.moderation_state_id 

63 

64 

65# ============================================================================ 

66# Tests for moderation helper functions 

67# ============================================================================ 

68 

69 

70def test_create_moderation(db): 

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

72 user, _ = generate_user() 

73 

74 with session_scope() as session: 

75 # Create a moderation state 

76 moderation_state = create_moderation( 

77 session=session, 

78 object_type=ModerationObjectType.HOST_REQUEST, 

79 object_id=123, 

80 creator_user_id=user.id, 

81 ) 

82 

83 assert moderation_state.object_type == ModerationObjectType.HOST_REQUEST 

84 assert moderation_state.object_id == 123 

85 assert moderation_state.visibility == ModerationVisibility.SHADOWED 

86 

87 # Check that log entry was created 

88 log_entries = ( 

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

90 .scalars() 

91 .all() 

92 ) 

93 

94 assert len(log_entries) == 1 

95 assert log_entries[0].action == ModerationAction.CREATE 

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

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

98 

99 

100def test_add_to_moderation_queue(db): 

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

102 super_user, super_token = generate_user(is_superuser=True) 

103 user1, token1 = generate_user() 

104 user2, _ = generate_user() 

105 

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

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

108 

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

110 with requests_session(token1) as api: 

111 host_request_id = api.CreateHostRequest( 

112 requests_pb2.CreateHostRequestReq( 

113 host_user_id=user2.id, 

114 from_date=today_plus_2, 

115 to_date=today_plus_3, 

116 text=valid_request_text(), 

117 ) 

118 ).host_request_id 

119 

120 # Get the moderation state ID 

121 state_id = None 

122 with session_scope() as session: 

123 host_request = session.execute( 

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

125 ).scalar_one() 

126 state_id = host_request.moderation_state_id 

127 

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

129 with real_moderation_session(super_token) as api: 

130 res = api.FlagContentForReview( 

131 moderation_pb2.FlagContentForReviewReq( 

132 moderation_state_id=state_id, 

133 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

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

135 ) 

136 ) 

137 

138 assert res.queue_item.moderation_state_id == state_id 

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

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

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

142 assert res.queue_item.is_resolved == False 

143 

144 

145def test_moderate_content(db): 

146 """Test moderating content via API""" 

147 super_user, super_token = generate_user(is_superuser=True) 

148 user, token = generate_user() 

149 host, _ = generate_user() 

150 

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

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

153 

154 # Create a real host request 

155 state_id = None 

156 with requests_session(token) as api: 

157 hr_id = api.CreateHostRequest( 

158 requests_pb2.CreateHostRequestReq( 

159 host_user_id=host.id, 

160 from_date=today_plus_2, 

161 to_date=today_plus_3, 

162 text=valid_request_text(), 

163 ) 

164 ).host_request_id 

165 

166 with session_scope() as session: 

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

168 state_id = hr.moderation_state_id 

169 

170 # Moderate the content via API 

171 with real_moderation_session(super_token) as api: 

172 res = api.ModerateContent( 

173 moderation_pb2.ModerateContentReq( 

174 moderation_state_id=state_id, 

175 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

176 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

177 reason="Content looks good", 

178 ) 

179 ) 

180 

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

182 

183 # Check that state was updated in database 

184 with session_scope() as session: 

185 updated_state = session.get_one(ModerationState, state_id) 

186 assert updated_state.visibility == ModerationVisibility.VISIBLE 

187 

188 # Check that log entry was created 

189 log_entries = ( 

190 session.execute( 

191 select(ModerationLog) 

192 .where(ModerationLog.moderation_state_id == state_id) 

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

194 ) 

195 .scalars() 

196 .all() 

197 ) 

198 

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

200 assert log_entries[0].action == ModerationAction.APPROVE 

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

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

203 

204 

205def test_resolve_queue_item(db): 

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

207 user1, token1 = generate_user() 

208 user2, _ = generate_user() 

209 moderator, moderator_token = generate_user(is_superuser=True) 

210 

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

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

213 

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

215 with requests_session(token1) as api: 

216 host_request_id = api.CreateHostRequest( 

217 requests_pb2.CreateHostRequestReq( 

218 host_user_id=user2.id, 

219 from_date=today_plus_2, 

220 to_date=today_plus_3, 

221 text=valid_request_text(), 

222 ) 

223 ).host_request_id 

224 

225 state_id = None 

226 with session_scope() as session: 

227 # Get the host request and its moderation state 

228 host_request = session.execute( 

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

230 ).scalar_one() 

231 state_id = host_request.moderation_state_id 

232 

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

234 queue_item = session.execute( 

235 select(ModerationQueueItem) 

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

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

238 ).scalar_one() 

239 

240 assert queue_item.resolved_by_log_id is None 

241 

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

243 with real_moderation_session(moderator_token) as api: 

244 api.ModerateContent( 

245 moderation_pb2.ModerateContentReq( 

246 moderation_state_id=state_id, 

247 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

248 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

249 reason="Approved after review", 

250 ) 

251 ) 

252 

253 # Check that queue item was resolved 

254 with session_scope() as session: 

255 queue_item = session.execute( 

256 select(ModerationQueueItem) 

257 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

259 ).scalar_one() 

260 assert queue_item.resolved_by_log_id is not None 

261 

262 

263def test_approve_content_via_api(db): 

264 """Test approving content via ModerateContent API""" 

265 user1, token1 = generate_user() 

266 user2, _ = generate_user() 

267 moderator, moderator_token = generate_user(is_superuser=True) 

268 

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

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

271 

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

273 with requests_session(token1) as api: 

274 host_request_id = api.CreateHostRequest( 

275 requests_pb2.CreateHostRequestReq( 

276 host_user_id=user2.id, 

277 from_date=today_plus_2, 

278 to_date=today_plus_3, 

279 text=valid_request_text(), 

280 ) 

281 ).host_request_id 

282 

283 state_id = None 

284 with session_scope() as session: 

285 # Get the host request and its moderation state 

286 host_request = session.execute( 

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

288 ).scalar_one() 

289 state_id = host_request.moderation_state_id 

290 

291 # Approve via API 

292 with real_moderation_session(moderator_token) as api: 

293 api.ModerateContent( 

294 moderation_pb2.ModerateContentReq( 

295 moderation_state_id=state_id, 

296 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

297 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

298 reason="Quick approval", 

299 ) 

300 ) 

301 

302 # Check that state was updated to VISIBLE 

303 with session_scope() as session: 

304 updated_state = session.get_one(ModerationState, state_id) 

305 assert updated_state.visibility == ModerationVisibility.VISIBLE 

306 

307 # Check log entry 

308 log_entry = session.execute( 

309 select(ModerationLog) 

310 .where(ModerationLog.moderation_state_id == state_id) 

311 .where(ModerationLog.action == ModerationAction.APPROVE) 

312 ).scalar_one() 

313 

314 assert log_entry.moderator_user_id == moderator.id 

315 assert log_entry.reason == "Quick approval" 

316 

317 

318# ============================================================================ 

319# Tests for host request moderation integration 

320# ============================================================================ 

321 

322 

323def test_create_host_request_creates_moderation_state(db): 

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

325 user1, token1 = generate_user() 

326 user2, token2 = generate_user() 

327 

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

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

330 

331 with requests_session(token1) as api: 

332 host_request_id = api.CreateHostRequest( 

333 requests_pb2.CreateHostRequestReq( 

334 host_user_id=user2.id, 

335 from_date=today_plus_2, 

336 to_date=today_plus_3, 

337 text=valid_request_text(), 

338 ) 

339 ).host_request_id 

340 

341 with session_scope() as session: 

342 # Check that host request has a moderation state 

343 host_request = session.execute( 

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

345 ).scalar_one() 

346 

347 # Check moderation state properties 

348 moderation_state = session.execute( 

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

350 ).scalar_one() 

351 

352 assert moderation_state.object_type == ModerationObjectType.HOST_REQUEST 

353 assert moderation_state.object_id == host_request_id 

354 assert moderation_state.visibility == ModerationVisibility.SHADOWED 

355 

356 # Check that it was added to moderation queue 

357 queue_items = ( 

358 session.execute( 

359 select(ModerationQueueItem) 

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

361 .where(ModerationQueueItem.resolved_by_log_id == None) 

362 ) 

363 .scalars() 

364 .all() 

365 ) 

366 

367 assert len(queue_items) == 1 

368 assert queue_items[0].trigger == ModerationTrigger.INITIAL_REVIEW 

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

370 

371 

372def test_host_request_no_notification_before_approval(db, push_collector: PushCollector): 

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

374 user1, token1 = generate_user() 

375 user2, token2 = generate_user() 

376 

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

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

379 

380 with requests_session(token1) as api: 

381 host_request_id = api.CreateHostRequest( 

382 requests_pb2.CreateHostRequestReq( 

383 host_user_id=user2.id, 

384 from_date=today_plus_2, 

385 to_date=today_plus_3, 

386 text=valid_request_text(), 

387 ) 

388 ).host_request_id 

389 

390 # Process all jobs (including the notification job) 

391 process_jobs() 

392 

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

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

395 

396 

397def test_shadowed_notification_not_in_list_notifications(db): 

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

399 user1, token1 = generate_user() 

400 user2, token2 = generate_user() 

401 

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

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

404 

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

406 with requests_session(token1) as api: 

407 host_request_id = api.CreateHostRequest( 

408 requests_pb2.CreateHostRequestReq( 

409 host_user_id=user2.id, 

410 from_date=today_plus_2, 

411 to_date=today_plus_3, 

412 text=valid_request_text(), 

413 ) 

414 ).host_request_id 

415 

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

417 with notifications_session(token2) as api: 

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

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

420 assert len(res.notifications) == 0 

421 

422 

423def test_notification_visible_after_approval(db): 

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

425 user1, token1 = generate_user() 

426 user2, token2 = generate_user() 

427 mod, mod_token = generate_user(is_superuser=True) 

428 

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

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

431 

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

433 with requests_session(token1) as api: 

434 host_request_id = api.CreateHostRequest( 

435 requests_pb2.CreateHostRequestReq( 

436 host_user_id=user2.id, 

437 from_date=today_plus_2, 

438 to_date=today_plus_3, 

439 text=valid_request_text(), 

440 ) 

441 ).host_request_id 

442 

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

444 with notifications_session(token2) as api: 

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

446 assert len(res.notifications) == 0 

447 

448 # Get the moderation state ID and approve 

449 with session_scope() as session: 

450 host_request = session.execute( 

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

452 ).scalar_one() 

453 state_id = host_request.moderation_state_id 

454 

455 with real_moderation_session(mod_token) as api: 

456 api.ModerateContent( 

457 moderation_pb2.ModerateContentReq( 

458 moderation_state_id=state_id, 

459 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

460 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

461 reason="Looks good", 

462 ) 

463 ) 

464 

465 # Now host SHOULD see the notification 

466 with notifications_session(token2) as api: 

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

468 assert len(res.notifications) == 1 

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

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

471 

472 

473def test_shadowed_host_request_visible_to_author_only(db): 

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

475 user1, token1 = generate_user() 

476 user2, token2 = generate_user() 

477 

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

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

480 

481 with requests_session(token1) as api: 

482 host_request_id = api.CreateHostRequest( 

483 requests_pb2.CreateHostRequestReq( 

484 host_user_id=user2.id, 

485 from_date=today_plus_2, 

486 to_date=today_plus_3, 

487 text=valid_request_text(), 

488 ) 

489 ).host_request_id 

490 

491 # Surfer (author) can see it with GetHostRequest 

492 with requests_session(token1) as api: 

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

494 assert res.host_request_id == host_request_id 

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

496 

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

498 with requests_session(token2) as api: 

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

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

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

502 

503 

504def test_unlisted_host_request_not_in_lists(db): 

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

506 user1, token1 = generate_user() 

507 user2, token2 = generate_user() 

508 

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

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

511 

512 with requests_session(token1) as api: 

513 host_request_id = api.CreateHostRequest( 

514 requests_pb2.CreateHostRequestReq( 

515 host_user_id=user2.id, 

516 from_date=today_plus_2, 

517 to_date=today_plus_3, 

518 text=valid_request_text(), 

519 ) 

520 ).host_request_id 

521 

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

523 with requests_session(token1) as api: 

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

525 assert len(res.host_requests) == 1 

526 

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

528 with requests_session(token2) as api: 

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

530 assert len(res.host_requests) == 0 

531 

532 

533def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector): 

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

535 user1, token1 = generate_user() 

536 user2, token2 = generate_user() 

537 mod, mod_token = generate_user(is_superuser=True) 

538 

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

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

541 

542 with requests_session(token1) as api: 

543 host_request_id = api.CreateHostRequest( 

544 requests_pb2.CreateHostRequestReq( 

545 host_user_id=user2.id, 

546 from_date=today_plus_2, 

547 to_date=today_plus_3, 

548 text=valid_request_text(), 

549 ) 

550 ).host_request_id 

551 

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

553 process_jobs() 

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

555 

556 # Get the moderation state ID 

557 state_id = None 

558 with session_scope() as session: 

559 host_request = session.execute( 

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

561 ).scalar_one() 

562 state_id = host_request.moderation_state_id 

563 

564 # Approve the host request via API 

565 with real_moderation_session(mod_token) as api: 

566 api.ModerateContent( 

567 moderation_pb2.ModerateContentReq( 

568 moderation_state_id=state_id, 

569 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

570 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

571 reason="Looks good", 

572 ) 

573 ) 

574 

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

576 process_jobs() 

577 

578 # Now surfer SHOULD see it in their sent list 

579 with requests_session(token1) as api: 

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

581 assert len(res.host_requests) == 1 

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

583 

584 # Host SHOULD see it in their received list 

585 with requests_session(token2) as api: 

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

587 assert len(res.host_requests) == 1 

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

589 

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

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

592 

593 

594def test_hidden_host_request_invisible_to_all(db): 

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

596 user1, token1 = generate_user() 

597 user2, token2 = generate_user() 

598 user3, token3 = generate_user() # Third party 

599 moderator, moderator_token = generate_user(is_superuser=True) 

600 

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

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

603 

604 with requests_session(token1) as api: 

605 host_request_id = api.CreateHostRequest( 

606 requests_pb2.CreateHostRequestReq( 

607 host_user_id=user2.id, 

608 from_date=today_plus_2, 

609 to_date=today_plus_3, 

610 text=valid_request_text(), 

611 ) 

612 ).host_request_id 

613 

614 # Get the moderation state ID 

615 state_id = None 

616 with session_scope() as session: 

617 host_request = session.execute( 

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

619 ).scalar_one() 

620 state_id = host_request.moderation_state_id 

621 

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

623 with real_moderation_session(moderator_token) as api: 

624 api.ModerateContent( 

625 moderation_pb2.ModerateContentReq( 

626 moderation_state_id=state_id, 

627 action=moderation_pb2.MODERATION_ACTION_HIDE, 

628 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

629 reason="Spam content", 

630 ) 

631 ) 

632 

633 # Surfer can't see it with GetHostRequest 

634 with requests_session(token1) as api: 

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

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

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

638 

639 # Host can't see it with GetHostRequest 

640 with requests_session(token2) as api: 

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

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

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

644 

645 # Third party definitely can't see it 

646 with requests_session(token3) as api: 

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

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

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

650 

651 # Not in any lists 

652 with requests_session(token1) as api: 

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

654 assert len(res.host_requests) == 0 

655 

656 with requests_session(token2) as api: 

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

658 assert len(res.host_requests) == 0 

659 

660 

661def test_multiple_host_requests_listing_visibility(db): 

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

663 user1, token1 = generate_user() 

664 user2, token2 = generate_user() 

665 moderator, moderator_token = generate_user(is_superuser=True) 

666 

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

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

669 

670 # Create 3 host requests 

671 host_request_ids = [] 

672 state_ids = [] 

673 with requests_session(token1) as api: 

674 for i in range(3): 

675 hr_id = api.CreateHostRequest( 

676 requests_pb2.CreateHostRequestReq( 

677 host_user_id=user2.id, 

678 from_date=today_plus_2, 

679 to_date=today_plus_3, 

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

681 ) 

682 ).host_request_id 

683 host_request_ids.append(hr_id) 

684 

685 # Get state IDs 

686 with session_scope() as session: 

687 for hr_id in host_request_ids: 

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

689 state_ids.append(host_request.moderation_state_id) 

690 

691 # Approve the first one via API 

692 with real_moderation_session(moderator_token) as api: 

693 api.ModerateContent( 

694 moderation_pb2.ModerateContentReq( 

695 moderation_state_id=state_ids[0], 

696 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

697 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

698 reason="Approved", 

699 ) 

700 ) 

701 

702 # Hide the third one via API 

703 with real_moderation_session(moderator_token) as api: 

704 api.ModerateContent( 

705 moderation_pb2.ModerateContentReq( 

706 moderation_state_id=state_ids[2], 

707 action=moderation_pb2.MODERATION_ACTION_HIDE, 

708 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

709 reason="Spam", 

710 ) 

711 ) 

712 

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

714 with requests_session(token1) as api: 

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

716 assert len(res.host_requests) == 2 

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

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

719 

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

721 with requests_session(token2) as api: 

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

723 assert len(res.host_requests) == 1 

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

725 

726 

727def test_moderation_log_tracking(db): 

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

729 user, user_token = generate_user() 

730 host, _ = generate_user() 

731 moderator1, moderator1_token = generate_user(is_superuser=True) 

732 moderator2, moderator2_token = generate_user(is_superuser=True) 

733 

734 # Create a real host request 

735 state_id = create_test_host_request_with_moderation(user_token, host.id) 

736 

737 # Perform several moderation actions via API 

738 with real_moderation_session(moderator1_token) as api: 

739 api.ModerateContent( 

740 moderation_pb2.ModerateContentReq( 

741 moderation_state_id=state_id, 

742 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

743 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

744 reason="Looks good initially", 

745 ) 

746 ) 

747 

748 with real_moderation_session(moderator2_token) as api: 

749 api.FlagContentForReview( 

750 moderation_pb2.FlagContentForReviewReq( 

751 moderation_state_id=state_id, 

752 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

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

754 ) 

755 ) 

756 # Shadow it back 

757 api.ModerateContent( 

758 moderation_pb2.ModerateContentReq( 

759 moderation_state_id=state_id, 

760 action=moderation_pb2.MODERATION_ACTION_HIDE, 

761 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

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

763 ) 

764 ) 

765 

766 with real_moderation_session(moderator1_token) as api: 

767 api.ModerateContent( 

768 moderation_pb2.ModerateContentReq( 

769 moderation_state_id=state_id, 

770 action=moderation_pb2.MODERATION_ACTION_HIDE, 

771 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

772 reason="Actually it's spam", 

773 ) 

774 ) 

775 

776 # Check all log entries 

777 with session_scope() as session: 

778 log_entries = ( 

779 session.execute( 

780 select(ModerationLog) 

781 .where(ModerationLog.moderation_state_id == state_id) 

782 .order_by(ModerationLog.time.asc()) 

783 ) 

784 .scalars() 

785 .all() 

786 ) 

787 

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

789 assert len(log_entries) >= 3 

790 

791 assert log_entries[0].action == ModerationAction.CREATE 

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

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

794 

795 assert log_entries[1].action == ModerationAction.APPROVE 

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

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

798 

799 # The last action should be hiding 

800 assert log_entries[-1].action == ModerationAction.HIDE 

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

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

803 

804 

805def test_moderation_queue_workflow(db): 

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

807 user1, token1 = generate_user() 

808 user2, _ = generate_user() 

809 moderator, moderator_token = generate_user(is_superuser=True) 

810 

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

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

813 

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

815 with requests_session(token1) as api: 

816 host_request_id = api.CreateHostRequest( 

817 requests_pb2.CreateHostRequestReq( 

818 host_user_id=user2.id, 

819 from_date=today_plus_2, 

820 to_date=today_plus_3, 

821 text=valid_request_text(), 

822 ) 

823 ).host_request_id 

824 

825 state_id = None 

826 queue_item_id = None 

827 with session_scope() as session: 

828 # Get the host request and its moderation state 

829 host_request = session.execute( 

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

831 ).scalar_one() 

832 state_id = host_request.moderation_state_id 

833 

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

835 queue_item = session.execute( 

836 select(ModerationQueueItem) 

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

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

839 ).scalar_one() 

840 queue_item_id = queue_item.id 

841 

842 # Verify it's in the queue 

843 unresolved_items = ( 

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

845 .scalars() 

846 .all() 

847 ) 

848 

849 assert len(unresolved_items) >= 1 

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

851 

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

853 with real_moderation_session(moderator_token) as api: 

854 api.ModerateContent( 

855 moderation_pb2.ModerateContentReq( 

856 moderation_state_id=state_id, 

857 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

858 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

859 reason="Content approved", 

860 ) 

861 ) 

862 

863 # Verify queue item was resolved 

864 with session_scope() as session: 

865 # Verify it's no longer in unresolved queue 

866 unresolved_items = ( 

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

868 .scalars() 

869 .all() 

870 ) 

871 

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

873 

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

875 queue_item = session.get_one(ModerationQueueItem, queue_item_id) 

876 assert queue_item.resolved_by_log_id is not None 

877 

878 

879# ============================================================================ 

880# Moderation API Tests (testing the gRPC servicer) 

881# ============================================================================ 

882 

883 

884def test_GetModerationQueue_empty(db): 

885 """Test getting an empty moderation queue""" 

886 super_user, super_token = generate_user(is_superuser=True) 

887 

888 with real_moderation_session(super_token) as api: 

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

890 assert len(res.queue_items) == 0 

891 assert res.next_page_token == "" 

892 

893 

894def test_GetModerationQueue_with_items(db): 

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

896 super_user, super_token = generate_user(is_superuser=True) 

897 normal_user, user_token = generate_user() 

898 host, _ = generate_user() 

899 

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

901 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

902 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

903 

904 with real_moderation_session(super_token) as api: 

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

906 assert len(res.queue_items) == 2 

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

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

909 

910 

911def test_GetModerationQueue_filter_by_trigger(db): 

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

913 super_user, super_token = generate_user(is_superuser=True) 

914 normal_user, user_token = generate_user() 

915 host, _ = generate_user() 

916 

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

918 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

919 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

920 

921 # Add USER_FLAG trigger to second item via API 

922 with real_moderation_session(super_token) as api: 

923 api.FlagContentForReview( 

924 moderation_pb2.FlagContentForReviewReq( 

925 moderation_state_id=state2_id, 

926 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

927 reason="Reported by user", 

928 ) 

929 ) 

930 

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

932 with real_moderation_session(super_token) as api: 

933 res = api.GetModerationQueue( 

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

935 ) 

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

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

938 

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

940 with real_moderation_session(super_token) as api: 

941 res = api.GetModerationQueue( 

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

943 ) 

944 assert len(res.queue_items) == 1 

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

946 

947 

948def test_GetModerationQueue_filter_created_before(db): 

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

950 super_user, super_token = generate_user(is_superuser=True) 

951 normal_user, user_token = generate_user() 

952 host, _ = generate_user() 

953 

954 # Create host requests 

955 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

956 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

957 

958 # Backdate the first queue item 

959 with session_scope() as session: 

960 queue_item1 = session.execute( 

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

962 ).scalar_one() 

963 # Set it to 2 hours ago 

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

965 

966 # The second item remains at current time 

967 

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

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

970 with real_moderation_session(super_token) as api: 

971 res = api.GetModerationQueue( 

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

973 ) 

974 assert len(res.queue_items) == 1 

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

976 

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

978 with real_moderation_session(super_token) as api: 

979 res = api.GetModerationQueue( 

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

981 ) 

982 assert len(res.queue_items) == 2 

983 

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

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

986 with real_moderation_session(super_token) as api: 

987 res = api.GetModerationQueue( 

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

989 ) 

990 assert len(res.queue_items) == 0 

991 

992 

993def test_GetModerationQueue_filter_created_after(db): 

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

995 super_user, super_token = generate_user(is_superuser=True) 

996 normal_user, user_token = generate_user() 

997 host, _ = generate_user() 

998 

999 # Create host requests 

1000 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1001 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1002 

1003 # Backdate the first queue item to 2 hours ago 

1004 with session_scope() as session: 

1005 queue_item1 = session.execute( 

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

1007 ).scalar_one() 

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

1009 

1010 # The second item remains at current time 

1011 

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

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

1014 with real_moderation_session(super_token) as api: 

1015 res = api.GetModerationQueue( 

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

1017 ) 

1018 assert len(res.queue_items) == 1 

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

1020 

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

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

1023 with real_moderation_session(super_token) as api: 

1024 res = api.GetModerationQueue( 

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

1026 ) 

1027 assert len(res.queue_items) == 2 

1028 

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

1030 with real_moderation_session(super_token) as api: 

1031 res = api.GetModerationQueue( 

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

1033 ) 

1034 assert len(res.queue_items) == 0 

1035 

1036 

1037def test_GetModerationQueue_filter_created_before_and_after(db): 

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

1039 super_user, super_token = generate_user(is_superuser=True) 

1040 normal_user, user_token = generate_user() 

1041 host, _ = generate_user() 

1042 

1043 # Create 3 host requests 

1044 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1045 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1046 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1047 

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

1049 with session_scope() as session: 

1050 queue_item1 = session.execute( 

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

1052 ).scalar_one() 

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

1054 

1055 queue_item2 = session.execute( 

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

1057 ).scalar_one() 

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

1059 

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

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

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

1063 with real_moderation_session(super_token) as api: 

1064 res = api.GetModerationQueue( 

1065 moderation_pb2.GetModerationQueueReq( 

1066 created_after=Timestamp_from_datetime(after_cutoff), 

1067 created_before=Timestamp_from_datetime(before_cutoff), 

1068 ) 

1069 ) 

1070 assert len(res.queue_items) == 1 

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

1072 

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

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

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

1076 with real_moderation_session(super_token) as api: 

1077 res = api.GetModerationQueue( 

1078 moderation_pb2.GetModerationQueueReq( 

1079 created_after=Timestamp_from_datetime(after_cutoff), 

1080 created_before=Timestamp_from_datetime(before_cutoff), 

1081 ) 

1082 ) 

1083 assert len(res.queue_items) == 1 

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

1085 

1086 

1087def test_GetModerationQueue_filter_unresolved(db): 

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

1089 super_user, super_token = generate_user(is_superuser=True) 

1090 normal_user, user_token = generate_user() 

1091 host, _ = generate_user() 

1092 

1093 # Create 2 host requests 

1094 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1095 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1096 

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

1098 with real_moderation_session(super_token) as api: 

1099 api.ModerateContent( 

1100 moderation_pb2.ModerateContentReq( 

1101 moderation_state_id=state1_id, 

1102 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1103 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1104 reason="Approved", 

1105 ) 

1106 ) 

1107 

1108 # Get all items 

1109 with real_moderation_session(super_token) as api: 

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

1111 assert len(res.queue_items) == 2 

1112 

1113 # Get only unresolved items 

1114 with real_moderation_session(super_token) as api: 

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

1116 assert len(res.queue_items) == 1 

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

1118 

1119 

1120def test_GetModerationQueue_filter_by_author(db): 

1121 """Test filtering moderation queue by item_author_user_id""" 

1122 super_user, super_token = generate_user(is_superuser=True) 

1123 user1, token1 = generate_user() 

1124 user2, token2 = generate_user() 

1125 host_user, _ = generate_user() 

1126 

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

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

1129 

1130 # Create 2 host requests by user1 

1131 with requests_session(token1) as api: 

1132 hr1_id = api.CreateHostRequest( 

1133 requests_pb2.CreateHostRequestReq( 

1134 host_user_id=host_user.id, 

1135 from_date=today_plus_2, 

1136 to_date=today_plus_3, 

1137 text=valid_request_text(), 

1138 ) 

1139 ).host_request_id 

1140 

1141 hr2_id = api.CreateHostRequest( 

1142 requests_pb2.CreateHostRequestReq( 

1143 host_user_id=host_user.id, 

1144 from_date=today_plus_2, 

1145 to_date=today_plus_3, 

1146 text=valid_request_text(), 

1147 ) 

1148 ).host_request_id 

1149 

1150 # Create 1 host request by user2 

1151 with requests_session(token2) as api: 

1152 hr3_id = api.CreateHostRequest( 

1153 requests_pb2.CreateHostRequestReq( 

1154 host_user_id=host_user.id, 

1155 from_date=today_plus_2, 

1156 to_date=today_plus_3, 

1157 text=valid_request_text(), 

1158 ) 

1159 ).host_request_id 

1160 

1161 # Get moderation state IDs 

1162 state1_id, state2_id, state3_id = None, None, None 

1163 with session_scope() as session: 

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

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

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

1167 state1_id = hr1.moderation_state_id 

1168 state2_id = hr2.moderation_state_id 

1169 state3_id = hr3.moderation_state_id 

1170 

1171 # Get all items (should be 3) 

1172 with real_moderation_session(super_token) as api: 

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

1174 assert len(res.queue_items) == 3 

1175 

1176 # Filter by user1 (should get 2) 

1177 with real_moderation_session(super_token) as api: 

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

1179 assert len(res.queue_items) == 2 

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

1181 

1182 # Filter by user2 (should get 1) 

1183 with real_moderation_session(super_token) as api: 

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

1185 assert len(res.queue_items) == 1 

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

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

1188 

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

1190 with real_moderation_session(super_token) as api: 

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

1192 assert len(res.queue_items) == 0 

1193 

1194 

1195def test_GetModerationQueue_ordering(db): 

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

1197 super_user, super_token = generate_user(is_superuser=True) 

1198 normal_user, user_token = generate_user() 

1199 host, _ = generate_user() 

1200 

1201 # Create 3 host requests 

1202 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1203 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1204 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1205 

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

1207 with session_scope() as session: 

1208 queue_item1 = session.execute( 

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

1210 ).scalar_one() 

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

1212 

1213 queue_item2 = session.execute( 

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

1215 ).scalar_one() 

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

1217 

1218 queue_item3 = session.execute( 

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

1220 ).scalar_one() 

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

1222 

1223 # Default order (oldest first) 

1224 with real_moderation_session(super_token) as api: 

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

1226 assert len(res.queue_items) == 3 

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

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

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

1230 

1231 # Explicit oldest first 

1232 with real_moderation_session(super_token) as api: 

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

1234 assert len(res.queue_items) == 3 

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

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

1237 

1238 # Newest first 

1239 with real_moderation_session(super_token) as api: 

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

1241 assert len(res.queue_items) == 3 

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

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

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

1245 

1246 

1247def test_GetModerationQueue_pagination_newest_first(db): 

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

1249 super_user, super_token = generate_user(is_superuser=True) 

1250 normal_user, normal_token = generate_user() 

1251 host_user, _ = generate_user() 

1252 

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

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

1255 

1256 # Create 5 host requests 

1257 hr_ids = [] 

1258 with requests_session(normal_token) as api: 

1259 for i in range(5): 

1260 hr_id = api.CreateHostRequest( 

1261 requests_pb2.CreateHostRequestReq( 

1262 host_user_id=host_user.id, 

1263 from_date=today_plus_2, 

1264 to_date=today_plus_3, 

1265 text=valid_request_text(), 

1266 ) 

1267 ).host_request_id 

1268 hr_ids.append(hr_id) 

1269 

1270 # Get moderation state IDs 

1271 state_ids = [] 

1272 with session_scope() as session: 

1273 for hr_id in hr_ids: 

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

1275 state_ids.append(hr.moderation_state_id) 

1276 

1277 # Set different times so ordering is deterministic 

1278 with session_scope() as session: 

1279 for i, state_id in enumerate(state_ids): 

1280 queue_item = session.execute( 

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

1282 ).scalar_one() 

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

1284 

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

1286 with real_moderation_session(super_token) as api: 

1287 res1 = api.GetModerationQueue( 

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

1289 ) 

1290 assert len(res1.queue_items) == 2 

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

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

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

1294 assert res1.next_page_token # should have more pages 

1295 

1296 # Get second page using the token 

1297 res2 = api.GetModerationQueue( 

1298 moderation_pb2.GetModerationQueueReq( 

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

1300 ) 

1301 ) 

1302 assert len(res2.queue_items) == 2 

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

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

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

1306 

1307 # Pages should not overlap 

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

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

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

1311 

1312 

1313def test_GetModerationLog(db): 

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

1315 super_user, super_token = generate_user(is_superuser=True) 

1316 moderator, moderator_token = generate_user(is_superuser=True) 

1317 normal_user, user_token = generate_user() 

1318 host, _ = generate_user() 

1319 

1320 # Create a real host request 

1321 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1322 

1323 # Perform a moderation action via API 

1324 with real_moderation_session(moderator_token) as api: 

1325 api.ModerateContent( 

1326 moderation_pb2.ModerateContentReq( 

1327 moderation_state_id=state_id, 

1328 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1329 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1330 reason="Looks good", 

1331 ) 

1332 ) 

1333 

1334 with real_moderation_session(super_token) as api: 

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

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

1337 assert res.moderation_state.moderation_state_id == state_id 

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

1339 # Log entries are in reverse chronological order 

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

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

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

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

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

1345 

1346 

1347def test_GetModerationLog_not_found(db): 

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

1349 super_user, super_token = generate_user(is_superuser=True) 

1350 

1351 with real_moderation_session(super_token) as api: 

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

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

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

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

1356 

1357 

1358def test_GetModerationState(db): 

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

1360 super_user, super_token = generate_user(is_superuser=True) 

1361 user1, token1 = generate_user() 

1362 user2, _ = generate_user() 

1363 

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

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

1366 

1367 with requests_session(token1) as api: 

1368 host_request_id = api.CreateHostRequest( 

1369 requests_pb2.CreateHostRequestReq( 

1370 host_user_id=user2.id, 

1371 from_date=today_plus_2, 

1372 to_date=today_plus_3, 

1373 text=valid_request_text(), 

1374 ) 

1375 ).host_request_id 

1376 

1377 with real_moderation_session(super_token) as api: 

1378 res = api.GetModerationState( 

1379 moderation_pb2.GetModerationStateReq( 

1380 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1381 object_id=host_request_id, 

1382 ) 

1383 ) 

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

1385 assert res.moderation_state.object_id == host_request_id 

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

1387 assert res.moderation_state.moderation_state_id > 0 

1388 

1389 

1390def test_GetModerationState_not_found(db): 

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

1392 super_user, super_token = generate_user(is_superuser=True) 

1393 

1394 with real_moderation_session(super_token) as api: 

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

1396 api.GetModerationState( 

1397 moderation_pb2.GetModerationStateReq( 

1398 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1399 object_id=999999, 

1400 ) 

1401 ) 

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

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

1404 

1405 

1406def test_GetModerationState_unspecified_type(db): 

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

1408 super_user, super_token = generate_user(is_superuser=True) 

1409 

1410 with real_moderation_session(super_token) as api: 

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

1412 api.GetModerationState( 

1413 moderation_pb2.GetModerationStateReq( 

1414 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED, 

1415 object_id=123, 

1416 ) 

1417 ) 

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

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

1420 

1421 

1422def test_ModerateContent_approve(db): 

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

1424 super_user, super_token = generate_user(is_superuser=True) 

1425 user1, token1 = generate_user() 

1426 user2, _ = generate_user() 

1427 

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

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

1430 

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

1432 with requests_session(token1) as api: 

1433 host_request_id = api.CreateHostRequest( 

1434 requests_pb2.CreateHostRequestReq( 

1435 host_user_id=user2.id, 

1436 from_date=today_plus_2, 

1437 to_date=today_plus_3, 

1438 text=valid_request_text(), 

1439 ) 

1440 ).host_request_id 

1441 

1442 # Get the moderation state ID 

1443 state_id = None 

1444 with session_scope() as session: 

1445 host_request = session.execute( 

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

1447 ).scalar_one() 

1448 state_id = host_request.moderation_state_id 

1449 

1450 with real_moderation_session(super_token) as api: 

1451 res = api.ModerateContent( 

1452 moderation_pb2.ModerateContentReq( 

1453 moderation_state_id=state_id, 

1454 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1455 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1456 reason="Approved by admin", 

1457 ) 

1458 ) 

1459 assert res.moderation_state.moderation_state_id == state_id 

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

1461 

1462 # Verify state was updated in database 

1463 with session_scope() as session: 

1464 state = session.get_one(ModerationState, state_id) 

1465 assert state.visibility == ModerationVisibility.VISIBLE 

1466 

1467 

1468def test_ModerateContent_not_found(db): 

1469 """Test moderating non-existent content""" 

1470 super_user, super_token = generate_user(is_superuser=True) 

1471 

1472 with real_moderation_session(super_token) as api: 

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

1474 api.ModerateContent( 

1475 moderation_pb2.ModerateContentReq( 

1476 moderation_state_id=999999, 

1477 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1478 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1479 reason="Test", 

1480 ) 

1481 ) 

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

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

1484 

1485 

1486def test_ModerateContent_hide(db): 

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

1488 super_user, super_token = generate_user(is_superuser=True) 

1489 normal_user, user_token = generate_user() 

1490 host, _ = generate_user() 

1491 

1492 # Create a real host request 

1493 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1494 

1495 with real_moderation_session(super_token) as api: 

1496 res = api.ModerateContent( 

1497 moderation_pb2.ModerateContentReq( 

1498 moderation_state_id=state_id, 

1499 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1500 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

1501 reason="Spam content", 

1502 ) 

1503 ) 

1504 assert res.moderation_state.moderation_state_id == state_id 

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

1506 

1507 # Verify state was updated in database 

1508 with session_scope() as session: 

1509 state = session.get_one(ModerationState, state_id) 

1510 assert state.visibility == ModerationVisibility.HIDDEN 

1511 

1512 

1513def test_ModerateContent_shadow(db): 

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

1515 super_user, super_token = generate_user(is_superuser=True) 

1516 normal_user, user_token = generate_user() 

1517 host, _ = generate_user() 

1518 

1519 # Create a real host request 

1520 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1521 

1522 with real_moderation_session(super_token) as api: 

1523 res = api.ModerateContent( 

1524 moderation_pb2.ModerateContentReq( 

1525 moderation_state_id=state_id, 

1526 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1527 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

1528 reason="Needs further review", 

1529 ) 

1530 ) 

1531 assert res.moderation_state.moderation_state_id == state_id 

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

1533 

1534 # Verify state was updated in database 

1535 with session_scope() as session: 

1536 state = session.get_one(ModerationState, state_id) 

1537 assert state.visibility == ModerationVisibility.SHADOWED 

1538 

1539 

1540def test_FlagContentForReview(db): 

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

1542 super_user, super_token = generate_user(is_superuser=True) 

1543 user1, token1 = generate_user() 

1544 user2, _ = generate_user() 

1545 

1546 # Create a host request 

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

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

1549 

1550 with requests_session(token1) as api: 

1551 host_request_id = api.CreateHostRequest( 

1552 requests_pb2.CreateHostRequestReq( 

1553 host_user_id=user2.id, 

1554 from_date=today_plus_2, 

1555 to_date=today_plus_3, 

1556 text=valid_request_text(), 

1557 ) 

1558 ).host_request_id 

1559 

1560 # Get the moderation state ID 

1561 with session_scope() as session: 

1562 host_request = session.execute( 

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

1564 ).scalar_one() 

1565 state_id = host_request.moderation_state_id 

1566 

1567 with real_moderation_session(super_token) as api: 

1568 res = api.FlagContentForReview( 

1569 moderation_pb2.FlagContentForReviewReq( 

1570 moderation_state_id=state_id, 

1571 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

1572 reason="Admin flagged for additional review", 

1573 ) 

1574 ) 

1575 assert res.queue_item.moderation_state_id == state_id 

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

1577 assert res.queue_item.is_resolved == False 

1578 

1579 # Verify queue item was created in database 

1580 with session_scope() as session: 

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

1582 queue_item = ( 

1583 session.execute( 

1584 select(ModerationQueueItem) 

1585 .where(ModerationQueueItem.moderation_state_id == state_id) 

1586 .order_by(ModerationQueueItem.time_created.desc()) 

1587 ) 

1588 .scalars() 

1589 .first() 

1590 ) 

1591 assert queue_item 

1592 assert queue_item.trigger == ModerationTrigger.MODERATOR_REVIEW 

1593 assert queue_item.resolved_by_log_id is None 

1594 

1595 

1596# ============================================================================ 

1597# Tests for group chat moderation 

1598# ============================================================================ 

1599 

1600 

1601def test_group_chat_created_with_moderation_state(db): 

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

1603 user1, token1 = generate_user() 

1604 user2, _ = generate_user() 

1605 make_friends(user1, user2) 

1606 

1607 with conversations_session(token1) as api: 

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

1609 group_chat_id = res.group_chat_id 

1610 

1611 # Verify moderation state was created 

1612 with session_scope() as session: 

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

1614 

1615 assert group_chat.moderation_state.object_type == ModerationObjectType.GROUP_CHAT 

1616 assert group_chat.moderation_state.object_id == group_chat_id 

1617 # Group chats start as SHADOWED 

1618 assert group_chat.moderation_state.visibility == ModerationVisibility.SHADOWED 

1619 

1620 # A moderation queue item should have been created 

1621 queue_item = ( 

1622 session.execute( 

1623 select(ModerationQueueItem).where( 

1624 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id 

1625 ) 

1626 ) 

1627 .scalars() 

1628 .first() 

1629 ) 

1630 assert queue_item is not None 

1631 assert queue_item.trigger == ModerationTrigger.INITIAL_REVIEW 

1632 

1633 

1634def test_group_chat_GetModerationState(db): 

1635 """Test GetModerationState API for group chats""" 

1636 user1, token1 = generate_user() 

1637 user2, _ = generate_user() 

1638 moderator, mod_token = generate_user(is_superuser=True) 

1639 make_friends(user1, user2) 

1640 

1641 with conversations_session(token1) as api: 

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

1643 group_chat_id = res.group_chat_id 

1644 

1645 # Moderator can look up the moderation state 

1646 with real_moderation_session(mod_token) as api: 

1647 res = api.GetModerationState( 

1648 moderation_pb2.GetModerationStateReq( 

1649 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1650 object_id=group_chat_id, 

1651 ) 

1652 ) 

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

1654 assert res.moderation_state.object_id == group_chat_id 

1655 # Starts as SHADOWED 

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

1657 

1658 

1659def test_group_chat_moderation_hide(db): 

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

1661 user1, token1 = generate_user() 

1662 user2, token2 = generate_user() 

1663 moderator, mod_token = generate_user(is_superuser=True) 

1664 make_friends(user1, user2) 

1665 

1666 with conversations_session(token1) as api: 

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

1668 group_chat_id = res.group_chat_id 

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

1670 

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

1672 with real_moderation_session(mod_token) as api: 

1673 state_res = api.GetModerationState( 

1674 moderation_pb2.GetModerationStateReq( 

1675 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1676 object_id=group_chat_id, 

1677 ) 

1678 ) 

1679 api.ModerateContent( 

1680 moderation_pb2.ModerateContentReq( 

1681 moderation_state_id=state_res.moderation_state.moderation_state_id, 

1682 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1683 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1684 reason="Approved", 

1685 ) 

1686 ) 

1687 

1688 # Both users can see the chat now 

1689 with conversations_session(token1) as api: 

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

1691 assert len(res.group_chats) == 1 

1692 

1693 with conversations_session(token2) as api: 

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

1695 assert len(res.group_chats) == 1 

1696 

1697 # Moderator hides the group chat 

1698 with real_moderation_session(mod_token) as api: 

1699 state_res = api.GetModerationState( 

1700 moderation_pb2.GetModerationStateReq( 

1701 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1702 object_id=group_chat_id, 

1703 ) 

1704 ) 

1705 api.ModerateContent( 

1706 moderation_pb2.ModerateContentReq( 

1707 moderation_state_id=state_res.moderation_state.moderation_state_id, 

1708 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1709 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

1710 reason="Inappropriate content", 

1711 ) 

1712 ) 

1713 

1714 # Neither user can see the chat now 

1715 with conversations_session(token1) as api: 

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

1717 assert len(res.group_chats) == 0 

1718 

1719 with conversations_session(token2) as api: 

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

1721 assert len(res.group_chats) == 0 

1722 

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

1724 with conversations_session(token1) as api: 

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

1726 assert len(res.messages) == 0 

1727 

1728 

1729def test_group_chat_moderation_shadow(db): 

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

1731 user1, token1 = generate_user() # Creator 

1732 user2, token2 = generate_user() # Participant 

1733 moderator, mod_token = generate_user(is_superuser=True) 

1734 make_friends(user1, user2) 

1735 

1736 with conversations_session(token1) as api: 

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

1738 group_chat_id = res.group_chat_id 

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

1740 

1741 # Moderator shadows the group chat 

1742 with real_moderation_session(mod_token) as api: 

1743 state_res = api.GetModerationState( 

1744 moderation_pb2.GetModerationStateReq( 

1745 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1746 object_id=group_chat_id, 

1747 ) 

1748 ) 

1749 api.ModerateContent( 

1750 moderation_pb2.ModerateContentReq( 

1751 moderation_state_id=state_res.moderation_state.moderation_state_id, 

1752 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1753 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

1754 reason="Needs review", 

1755 ) 

1756 ) 

1757 

1758 # Creator can see SHADOWED content in list operations 

1759 with conversations_session(token1) as api: 

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

1761 assert len(res.group_chats) == 1 

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

1763 

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

1765 with conversations_session(token2) as api: 

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

1767 assert len(res.group_chats) == 0 

1768 

1769 # Creator can also access it directly via GetGroupChat 

1770 with conversations_session(token1) as api: 

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

1772 assert res.group_chat_id == group_chat_id 

1773 

1774 

1775# ============================================================================ 

1776# Tests for auto-approval background job 

1777# ============================================================================ 

1778 

1779 

1780def test_auto_approve_moderation_queue_disabled_when_zero(db): 

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

1782 moderator, mod_token = generate_user(is_superuser=True) 

1783 user1, token1 = generate_user() 

1784 user2, token2 = generate_user() 

1785 

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

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

1788 

1789 # Create a host request 

1790 with requests_session(token1) as api: 

1791 with mock_notification_email() as mock: 

1792 host_request_id = api.CreateHostRequest( 

1793 requests_pb2.CreateHostRequestReq( 

1794 host_user_id=user2.id, 

1795 from_date=today_plus_2, 

1796 to_date=today_plus_3, 

1797 text=valid_request_text(), 

1798 ) 

1799 ).host_request_id 

1800 

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

1802 mock.assert_not_called() 

1803 

1804 # Ensure deadline is 0 (disabled) 

1805 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 0 

1806 

1807 # Run the job 

1808 auto_approve_moderation_queue(empty_pb2.Empty()) 

1809 

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

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

1812 assert res.host_request_id == host_request_id 

1813 

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

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

1816 assert len(res.host_requests) == 1 

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

1818 

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

1820 with requests_session(token2) as api: 

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

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

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

1824 

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

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

1827 assert len(res.host_requests) == 0 

1828 

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

1830 with real_moderation_session(mod_token) as api: 

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

1832 assert len(res.queue_items) == 1 

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

1834 

1835 # Moderator can check the state is still SHADOWED 

1836 state_res = api.GetModerationState( 

1837 moderation_pb2.GetModerationStateReq( 

1838 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1839 object_id=host_request_id, 

1840 ) 

1841 ) 

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

1843 

1844 

1845def test_auto_approve_moderation_queue_approves_old_items(db, push_collector: PushCollector): 

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

1847 moderator, mod_token = generate_user(is_superuser=True) 

1848 user1, token1 = generate_user() 

1849 user2, token2 = generate_user() 

1850 

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

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

1853 

1854 # Create a host request 

1855 with requests_session(token1) as api: 

1856 with mock_notification_email() as mock: 

1857 host_request_id = api.CreateHostRequest( 

1858 requests_pb2.CreateHostRequestReq( 

1859 host_user_id=user2.id, 

1860 from_date=today_plus_2, 

1861 to_date=today_plus_3, 

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

1863 ) 

1864 ).host_request_id 

1865 

1866 # No email sent initially (shadowed) 

1867 mock.assert_not_called() 

1868 

1869 # Host cannot see the request yet 

1870 with requests_session(token2) as api: 

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

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

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

1874 

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

1876 with session_scope() as session: 

1877 host_request = session.execute( 

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

1879 ).scalar_one() 

1880 queue_item = session.execute( 

1881 select(ModerationQueueItem) 

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

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

1884 ).scalar_one() 

1885 # Backdate the queue item by 2 minutes 

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

1887 

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

1889 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 60 

1890 config["MODERATION_BOT_USER_ID"] = moderator.id 

1891 

1892 # Run the job 

1893 auto_approve_moderation_queue(empty_pb2.Empty()) 

1894 

1895 # Now host can see the request via API 

1896 with requests_session(token2) as api: 

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

1898 assert res.host_request_id == host_request_id 

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

1900 

1901 # Host sees it in their received list 

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

1903 assert len(res.host_requests) == 1 

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

1905 

1906 # Surfer sees it in their sent list 

1907 with requests_session(token1) as api: 

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

1909 assert len(res.host_requests) == 1 

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

1911 

1912 # Moderator sees the queue item is now resolved 

1913 with real_moderation_session(mod_token) as api: 

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

1915 assert len(res.queue_items) == 0 

1916 

1917 # State is now VISIBLE 

1918 state_res = api.GetModerationState( 

1919 moderation_pb2.GetModerationStateReq( 

1920 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1921 object_id=host_request_id, 

1922 ) 

1923 ) 

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

1925 

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

1927 log_res = api.GetModerationLog( 

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

1929 ) 

1930 # Find the APPROVE action 

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

1932 assert len(approve_entries) == 1 

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

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

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

1936 

1937 

1938def test_auto_approve_does_not_approve_recent_items(db): 

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

1940 moderator, mod_token = generate_user(is_superuser=True) 

1941 user1, token1 = generate_user() 

1942 user2, token2 = generate_user() 

1943 

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

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

1946 

1947 # Create a host request 

1948 with requests_session(token1) as api: 

1949 with mock_notification_email() as mock: 

1950 host_request_id = api.CreateHostRequest( 

1951 requests_pb2.CreateHostRequestReq( 

1952 host_user_id=user2.id, 

1953 from_date=today_plus_2, 

1954 to_date=today_plus_3, 

1955 text=valid_request_text(), 

1956 ) 

1957 ).host_request_id 

1958 

1959 # No email sent (shadowed) 

1960 mock.assert_not_called() 

1961 

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

1963 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 3600 

1964 config["MODERATION_BOT_USER_ID"] = moderator.id 

1965 

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

1967 with mock_notification_email() as mock: 

1968 auto_approve_moderation_queue(empty_pb2.Empty()) 

1969 

1970 # Still no email sent 

1971 mock.assert_not_called() 

1972 

1973 # Host still cannot see the request 

1974 with requests_session(token2) as api: 

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

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

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

1978 

1979 # Not in host's received list 

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

1981 assert len(res.host_requests) == 0 

1982 

1983 # Moderator sees it still in queue unresolved 

1984 with real_moderation_session(mod_token) as api: 

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

1986 assert len(res.queue_items) == 1 

1987 

1988 # State is still SHADOWED 

1989 state_res = api.GetModerationState( 

1990 moderation_pb2.GetModerationStateReq( 

1991 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1992 object_id=host_request_id, 

1993 ) 

1994 ) 

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

1996 

1997 

1998def test_auto_approve_does_not_approve_already_approved(db): 

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

2000 moderator, mod_token = generate_user(is_superuser=True) 

2001 user1, token1 = generate_user() 

2002 user2, token2 = generate_user() 

2003 

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

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

2006 

2007 # Create a host request 

2008 with requests_session(token1) as api: 

2009 host_request_id = api.CreateHostRequest( 

2010 requests_pb2.CreateHostRequestReq( 

2011 host_user_id=user2.id, 

2012 from_date=today_plus_2, 

2013 to_date=today_plus_3, 

2014 text=valid_request_text(), 

2015 ) 

2016 ).host_request_id 

2017 

2018 # Moderator approves it manually 

2019 with real_moderation_session(mod_token) as api: 

2020 state_res = api.GetModerationState( 

2021 moderation_pb2.GetModerationStateReq( 

2022 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2023 object_id=host_request_id, 

2024 ) 

2025 ) 

2026 state_id = state_res.moderation_state.moderation_state_id 

2027 

2028 api.ModerateContent( 

2029 moderation_pb2.ModerateContentReq( 

2030 moderation_state_id=state_id, 

2031 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

2032 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

2033 reason="Approved by moderator", 

2034 ) 

2035 ) 

2036 

2037 # Host can now see it 

2038 with requests_session(token2) as api: 

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

2040 assert res.host_request_id == host_request_id 

2041 

2042 # Get log count before auto-approval 

2043 with real_moderation_session(mod_token) as api: 

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

2045 log_count_before = len(log_res_before.log_entries) 

2046 

2047 # Set deadline to 1 second 

2048 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1 

2049 config["MODERATION_BOT_USER_ID"] = moderator.id 

2050 

2051 # Run the job 

2052 auto_approve_moderation_queue(empty_pb2.Empty()) 

2053 

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

2055 with real_moderation_session(mod_token) as api: 

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

2057 assert len(log_res_after.log_entries) == log_count_before 

2058 

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

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

2061 assert len(queue_res.queue_items) == 0 

2062 

2063 

2064def test_auto_approve_does_not_approve_moderator_shadowed_items(db): 

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

2066 moderator, mod_token = generate_user(is_superuser=True) 

2067 user1, token1 = generate_user() 

2068 user2, token2 = generate_user() 

2069 

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

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

2072 

2073 # Create a host request 

2074 with requests_session(token1) as api: 

2075 host_request_id = api.CreateHostRequest( 

2076 requests_pb2.CreateHostRequestReq( 

2077 host_user_id=user2.id, 

2078 from_date=today_plus_2, 

2079 to_date=today_plus_3, 

2080 text=valid_request_text(), 

2081 ) 

2082 ).host_request_id 

2083 

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

2085 with real_moderation_session(mod_token) as api: 

2086 state_res = api.GetModerationState( 

2087 moderation_pb2.GetModerationStateReq( 

2088 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2089 object_id=host_request_id, 

2090 ) 

2091 ) 

2092 state_id = state_res.moderation_state.moderation_state_id 

2093 

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

2095 api.ModerateContent( 

2096 moderation_pb2.ModerateContentReq( 

2097 moderation_state_id=state_id, 

2098 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2099 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

2100 reason="Keeping shadowed for review", 

2101 ) 

2102 ) 

2103 

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

2105 with session_scope() as session: 

2106 queue_item = session.execute( 

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

2108 ).scalar_one() 

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

2110 

2111 # Set deadline to 1 second 

2112 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1 

2113 config["MODERATION_BOT_USER_ID"] = moderator.id 

2114 

2115 # Get log count before 

2116 with real_moderation_session(mod_token) as api: 

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

2118 log_count_before = len(log_res_before.log_entries) 

2119 

2120 # Run the job 

2121 auto_approve_moderation_queue(empty_pb2.Empty()) 

2122 

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

2124 with real_moderation_session(mod_token) as api: 

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

2126 assert len(log_res_after.log_entries) == log_count_before 

2127 

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

2129 state_res = api.GetModerationState( 

2130 moderation_pb2.GetModerationStateReq( 

2131 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2132 object_id=host_request_id, 

2133 ) 

2134 ) 

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

2136 

2137 # Host still cannot see the request 

2138 with requests_session(token2) as api: 

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

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

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

2142 

2143 

2144# ============================================================================ 

2145# Notification Suppression Tests 

2146# ============================================================================ 

2147 

2148 

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

2150 """ 

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

2152 that haven't been approved yet. 

2153 """ 

2154 host, host_token = generate_user(complete_profile=True) 

2155 surfer, surfer_token = generate_user(complete_profile=True) 

2156 

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

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

2159 

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

2161 with requests_session(surfer_token) as api: 

2162 hr_id = api.CreateHostRequest( 

2163 requests_pb2.CreateHostRequestReq( 

2164 host_user_id=host.id, 

2165 from_date=today_plus_2, 

2166 to_date=today_plus_3, 

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

2168 ) 

2169 ).host_request_id 

2170 

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

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

2173 

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

2175 with requests_session(surfer_token) as api: 

2176 api.SendHostRequestMessage( 

2177 requests_pb2.SendHostRequestMessageReq( 

2178 host_request_id=hr_id, 

2179 text="Follow-up message 1", 

2180 ) 

2181 ) 

2182 api.SendHostRequestMessage( 

2183 requests_pb2.SendHostRequestMessageReq( 

2184 host_request_id=hr_id, 

2185 text="Follow-up message 2", 

2186 ) 

2187 ) 

2188 

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

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

2191 

2192 # Now approve the request 

2193 with mock_notification_email(): 

2194 moderator.approve_host_request(hr_id) 

2195 

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

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

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

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

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

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

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

2203 

2204 

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

2206 """ 

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

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

2209 

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

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

2212 """ 

2213 host, host_token = generate_user(complete_profile=True) 

2214 surfer, surfer_token = generate_user(complete_profile=True) 

2215 

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

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

2218 

2219 # Create host request 

2220 with requests_session(surfer_token) as api: 

2221 hr_id = api.CreateHostRequest( 

2222 requests_pb2.CreateHostRequestReq( 

2223 host_user_id=host.id, 

2224 from_date=today_plus_2, 

2225 to_date=today_plus_3, 

2226 text=valid_request_text(), 

2227 ) 

2228 ).host_request_id 

2229 

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

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

2232 

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

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

2235 with requests_session(surfer_token) as api: 

2236 api.RespondHostRequest( 

2237 requests_pb2.RespondHostRequestReq( 

2238 host_request_id=hr_id, 

2239 status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED, 

2240 text="Actually, never mind", 

2241 ) 

2242 ) 

2243 

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

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

2246 

2247 

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

2249 """ 

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

2251 """ 

2252 host, host_token = generate_user(complete_profile=True) 

2253 surfer, surfer_token = generate_user(complete_profile=True) 

2254 

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

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

2257 

2258 # Create and approve host request 

2259 with requests_session(surfer_token) as api: 

2260 hr_id = api.CreateHostRequest( 

2261 requests_pb2.CreateHostRequestReq( 

2262 host_user_id=host.id, 

2263 from_date=today_plus_2, 

2264 to_date=today_plus_3, 

2265 text=valid_request_text(), 

2266 ) 

2267 ).host_request_id 

2268 

2269 with mock_notification_email(): 

2270 moderator.approve_host_request(hr_id) 

2271 

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

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

2274 

2275 # Host accepts the request - surfer should be notified 

2276 with requests_session(host_token) as api: 

2277 with mock_notification_email(): 

2278 api.RespondHostRequest( 

2279 requests_pb2.RespondHostRequestReq( 

2280 host_request_id=hr_id, 

2281 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

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

2283 ) 

2284 ) 

2285 

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

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

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

2289 

2290 # Surfer confirms - host should be notified 

2291 with requests_session(surfer_token) as api: 

2292 with mock_notification_email(): 

2293 api.RespondHostRequest( 

2294 requests_pb2.RespondHostRequestReq( 

2295 host_request_id=hr_id, 

2296 status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED, 

2297 text="See you then!", 

2298 ) 

2299 ) 

2300 

2301 # Host should now have received the confirmation notifications 

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

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

2304 

2305 

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

2307 """ 

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

2309 that haven't been approved yet. 

2310 """ 

2311 from couchers.jobs.worker import process_job 

2312 from couchers.models import GroupChat 

2313 

2314 user1, token1 = generate_user(complete_profile=True) 

2315 user2, token2 = generate_user(complete_profile=True) 

2316 

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

2318 with conversations_session(token1) as api: 

2319 res = api.CreateGroupChat( 

2320 conversations_pb2.CreateGroupChatReq( 

2321 recipient_user_ids=[user2.id], 

2322 ) 

2323 ) 

2324 gc_id = res.group_chat_id 

2325 

2326 # Verify initial state 

2327 with session_scope() as session: 

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

2329 assert gc.moderation_state.visibility == ModerationVisibility.SHADOWED 

2330 

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

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

2333 

2334 # Send messages BEFORE approval 

2335 with conversations_session(token1) as api: 

2336 api.SendMessage( 

2337 conversations_pb2.SendMessageReq( 

2338 group_chat_id=gc_id, 

2339 text="Hello before approval", 

2340 ) 

2341 ) 

2342 

2343 # Process the queued notification job 

2344 while process_job(): 

2345 pass 

2346 

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

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

2349 

2350 # Now approve the group chat 

2351 moderator.approve_group_chat(gc_id) 

2352 

2353 # Process the queued notification jobs from approval 

2354 while process_job(): 

2355 pass 

2356 

2357 # Verify moderation state after approval 

2358 with session_scope() as session: 

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

2360 assert gc.moderation_state.visibility == ModerationVisibility.VISIBLE 

2361 

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

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

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

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

2366 

2367 # Send a message AFTER approval 

2368 with conversations_session(token1) as api: 

2369 api.SendMessage( 

2370 conversations_pb2.SendMessageReq( 

2371 group_chat_id=gc_id, 

2372 text="Hello after approval", 

2373 ) 

2374 ) 

2375 

2376 # Process the queued notification job 

2377 while process_job(): 

2378 pass 

2379 

2380 # User2 should have received another notification 

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