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

999 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-20 11:53 +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 HostRequest, 

17 ModerationAction, 

18 ModerationLog, 

19 ModerationObjectType, 

20 ModerationQueueItem, 

21 ModerationState, 

22 ModerationTrigger, 

23 ModerationVisibility, 

24) 

25from couchers.moderation.utils import create_moderation 

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

27from couchers.utils import Timestamp_from_datetime, now, today 

28from tests.test_fixtures import ( # noqa 

29 conversations_session, 

30 db, 

31 email_fields, 

32 generate_user, 

33 mock_notification_email, 

34 moderator, 

35 notifications_session, 

36 process_jobs, 

37 push_collector, 

38 real_moderation_session, 

39 requests_session, 

40 testconfig, 

41) 

42from tests.test_requests import valid_request_text 

43 

44 

45@pytest.fixture(autouse=True) 

46def _(testconfig): 

47 pass 

48 

49 

50def create_test_host_request_with_moderation(surfer_token, host_user_id): 

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

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

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

54 

55 with requests_session(surfer_token) as api: 

56 hr_id = api.CreateHostRequest( 

57 requests_pb2.CreateHostRequestReq( 

58 host_user_id=host_user_id, 

59 from_date=today_plus_2, 

60 to_date=today_plus_3, 

61 text=valid_request_text(), 

62 ) 

63 ).host_request_id 

64 

65 with session_scope() as session: 

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

67 return hr.moderation_state_id 

68 

69 

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

71# Tests for moderation helper functions 

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

73 

74 

75def test_create_moderation(db): 

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

77 user, _ = generate_user() 

78 

79 with session_scope() as session: 

80 # Create a moderation state 

81 moderation_state = create_moderation( 

82 session=session, 

83 object_type=ModerationObjectType.HOST_REQUEST, 

84 object_id=123, 

85 creator_user_id=user.id, 

86 ) 

87 

88 assert moderation_state.id is not None 

89 assert moderation_state.object_type == ModerationObjectType.HOST_REQUEST 

90 assert moderation_state.object_id == 123 

91 assert moderation_state.visibility == ModerationVisibility.SHADOWED 

92 

93 # Check that log entry was created 

94 log_entries = ( 

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

96 .scalars() 

97 .all() 

98 ) 

99 

100 assert len(log_entries) == 1 

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

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

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

104 

105 

106def test_add_to_moderation_queue(db): 

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

108 super_user, super_token = generate_user(is_superuser=True) 

109 user1, token1 = generate_user() 

110 user2, _ = generate_user() 

111 

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

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

114 

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

116 with requests_session(token1) as api: 

117 host_request_id = api.CreateHostRequest( 

118 requests_pb2.CreateHostRequestReq( 

119 host_user_id=user2.id, 

120 from_date=today_plus_2, 

121 to_date=today_plus_3, 

122 text=valid_request_text(), 

123 ) 

124 ).host_request_id 

125 

126 # Get the moderation state ID 

127 state_id = None 

128 with session_scope() as session: 

129 host_request = session.execute( 

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

131 ).scalar_one() 

132 state_id = host_request.moderation_state_id 

133 

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

135 with real_moderation_session(super_token) as api: 

136 res = api.FlagContentForReview( 

137 moderation_pb2.FlagContentForReviewReq( 

138 moderation_state_id=state_id, 

139 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

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

141 ) 

142 ) 

143 

144 assert res.queue_item.moderation_state_id == state_id 

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

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

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

148 assert res.queue_item.is_resolved == False 

149 

150 

151def test_moderate_content(db): 

152 """Test moderating content via API""" 

153 super_user, super_token = generate_user(is_superuser=True) 

154 user, token = generate_user() 

155 host, _ = generate_user() 

156 

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

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

159 

160 # Create a real host request 

161 state_id = None 

162 with requests_session(token) as api: 

163 hr_id = api.CreateHostRequest( 

164 requests_pb2.CreateHostRequestReq( 

165 host_user_id=host.id, 

166 from_date=today_plus_2, 

167 to_date=today_plus_3, 

168 text=valid_request_text(), 

169 ) 

170 ).host_request_id 

171 

172 with session_scope() as session: 

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

174 state_id = hr.moderation_state_id 

175 

176 # Moderate the content via API 

177 with real_moderation_session(super_token) as api: 

178 res = api.ModerateContent( 

179 moderation_pb2.ModerateContentReq( 

180 moderation_state_id=state_id, 

181 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

182 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

183 reason="Content looks good", 

184 ) 

185 ) 

186 

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

188 

189 # Check that state was updated in database 

190 with session_scope() as session: 

191 updated_state = session.get(ModerationState, state_id) 

192 assert updated_state.visibility == ModerationVisibility.VISIBLE 

193 

194 # Check that log entry was created 

195 log_entries = ( 

196 session.execute( 

197 select(ModerationLog) 

198 .where(ModerationLog.moderation_state_id == state_id) 

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

200 ) 

201 .scalars() 

202 .all() 

203 ) 

204 

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

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

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

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

209 

210 

211def test_resolve_queue_item(db): 

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

213 user1, token1 = generate_user() 

214 user2, _ = generate_user() 

215 moderator, moderator_token = generate_user(is_superuser=True) 

216 

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

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

219 

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

221 with requests_session(token1) as api: 

222 host_request_id = api.CreateHostRequest( 

223 requests_pb2.CreateHostRequestReq( 

224 host_user_id=user2.id, 

225 from_date=today_plus_2, 

226 to_date=today_plus_3, 

227 text=valid_request_text(), 

228 ) 

229 ).host_request_id 

230 

231 state_id = None 

232 with session_scope() as session: 

233 # Get the host request and its moderation state 

234 host_request = session.execute( 

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

236 ).scalar_one() 

237 state_id = host_request.moderation_state_id 

238 

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

240 queue_item = session.execute( 

241 select(ModerationQueueItem) 

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

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

244 ).scalar_one() 

245 

246 assert queue_item.resolved_by_log_id is None 

247 

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

249 with real_moderation_session(moderator_token) as api: 

250 api.ModerateContent( 

251 moderation_pb2.ModerateContentReq( 

252 moderation_state_id=state_id, 

253 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

254 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

255 reason="Approved after review", 

256 ) 

257 ) 

258 

259 # Check that queue item was resolved 

260 with session_scope() as session: 

261 queue_item = session.execute( 

262 select(ModerationQueueItem) 

263 .where(ModerationQueueItem.moderation_state_id == state_id) 

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

265 ).scalar_one() 

266 assert queue_item.resolved_by_log_id is not None 

267 

268 

269def test_approve_content_via_api(db): 

270 """Test approving content via ModerateContent API""" 

271 user1, token1 = generate_user() 

272 user2, _ = generate_user() 

273 moderator, moderator_token = generate_user(is_superuser=True) 

274 

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

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

277 

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

279 with requests_session(token1) as api: 

280 host_request_id = api.CreateHostRequest( 

281 requests_pb2.CreateHostRequestReq( 

282 host_user_id=user2.id, 

283 from_date=today_plus_2, 

284 to_date=today_plus_3, 

285 text=valid_request_text(), 

286 ) 

287 ).host_request_id 

288 

289 state_id = None 

290 with session_scope() as session: 

291 # Get the host request and its moderation state 

292 host_request = session.execute( 

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

294 ).scalar_one() 

295 state_id = host_request.moderation_state_id 

296 

297 # Approve via API 

298 with real_moderation_session(moderator_token) as api: 

299 api.ModerateContent( 

300 moderation_pb2.ModerateContentReq( 

301 moderation_state_id=state_id, 

302 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

303 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

304 reason="Quick approval", 

305 ) 

306 ) 

307 

308 # Check that state was updated to VISIBLE 

309 with session_scope() as session: 

310 updated_state = session.get(ModerationState, state_id) 

311 assert updated_state.visibility == ModerationVisibility.VISIBLE 

312 

313 # Check log entry 

314 log_entry = session.execute( 

315 select(ModerationLog) 

316 .where(ModerationLog.moderation_state_id == state_id) 

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

318 ).scalar_one() 

319 

320 assert log_entry.moderator_user_id == moderator.id 

321 assert log_entry.reason == "Quick approval" 

322 

323 

324# ============================================================================ 

325# Tests for host request moderation integration 

326# ============================================================================ 

327 

328 

329def test_create_host_request_creates_moderation_state(db): 

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

331 user1, token1 = generate_user() 

332 user2, token2 = generate_user() 

333 

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

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

336 

337 with requests_session(token1) as api: 

338 host_request_id = api.CreateHostRequest( 

339 requests_pb2.CreateHostRequestReq( 

340 host_user_id=user2.id, 

341 from_date=today_plus_2, 

342 to_date=today_plus_3, 

343 text=valid_request_text(), 

344 ) 

345 ).host_request_id 

346 

347 with session_scope() as session: 

348 # Check that host request has a moderation state 

349 host_request = session.execute( 

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

351 ).scalar_one() 

352 

353 assert host_request.moderation_state_id is not None 

354 

355 # Check moderation state properties 

356 moderation_state = session.execute( 

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

358 ).scalar_one() 

359 

360 assert moderation_state.object_type == ModerationObjectType.HOST_REQUEST 

361 assert moderation_state.object_id == host_request_id 

362 assert moderation_state.visibility == ModerationVisibility.SHADOWED 

363 

364 # Check that it was added to moderation queue 

365 queue_items = ( 

366 session.execute( 

367 select(ModerationQueueItem) 

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

369 .where(ModerationQueueItem.resolved_by_log_id == None) 

370 ) 

371 .scalars() 

372 .all() 

373 ) 

374 

375 assert len(queue_items) == 1 

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

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

378 

379 

380def test_host_request_no_notification_before_approval(db, push_collector): 

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

382 user1, token1 = generate_user() 

383 user2, token2 = generate_user() 

384 

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

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

387 

388 with requests_session(token1) as api: 

389 host_request_id = api.CreateHostRequest( 

390 requests_pb2.CreateHostRequestReq( 

391 host_user_id=user2.id, 

392 from_date=today_plus_2, 

393 to_date=today_plus_3, 

394 text=valid_request_text(), 

395 ) 

396 ).host_request_id 

397 

398 # Process all jobs (including the notification job) 

399 process_jobs() 

400 

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

402 push_collector.assert_user_has_count(user2.id, 0) 

403 

404 

405def test_shadowed_notification_not_in_list_notifications(db): 

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

407 user1, token1 = generate_user() 

408 user2, token2 = generate_user() 

409 

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

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

412 

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

414 with requests_session(token1) as api: 

415 host_request_id = api.CreateHostRequest( 

416 requests_pb2.CreateHostRequestReq( 

417 host_user_id=user2.id, 

418 from_date=today_plus_2, 

419 to_date=today_plus_3, 

420 text=valid_request_text(), 

421 ) 

422 ).host_request_id 

423 

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

425 with notifications_session(token2) as api: 

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

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

428 assert len(res.notifications) == 0 

429 

430 

431def test_notification_visible_after_approval(db): 

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

433 user1, token1 = generate_user() 

434 user2, token2 = generate_user() 

435 mod, mod_token = generate_user(is_superuser=True) 

436 

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

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

439 

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

441 with requests_session(token1) as api: 

442 host_request_id = api.CreateHostRequest( 

443 requests_pb2.CreateHostRequestReq( 

444 host_user_id=user2.id, 

445 from_date=today_plus_2, 

446 to_date=today_plus_3, 

447 text=valid_request_text(), 

448 ) 

449 ).host_request_id 

450 

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

452 with notifications_session(token2) as api: 

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

454 assert len(res.notifications) == 0 

455 

456 # Get the moderation state ID and approve 

457 with session_scope() as session: 

458 host_request = session.execute( 

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

460 ).scalar_one() 

461 state_id = host_request.moderation_state_id 

462 

463 with real_moderation_session(mod_token) as api: 

464 api.ModerateContent( 

465 moderation_pb2.ModerateContentReq( 

466 moderation_state_id=state_id, 

467 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

468 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

469 reason="Looks good", 

470 ) 

471 ) 

472 

473 # Now host SHOULD see the notification 

474 with notifications_session(token2) as api: 

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

476 assert len(res.notifications) == 1 

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

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

479 

480 

481def test_shadowed_host_request_visible_to_author_only(db): 

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

483 user1, token1 = generate_user() 

484 user2, token2 = generate_user() 

485 

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

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

488 

489 with requests_session(token1) as api: 

490 host_request_id = api.CreateHostRequest( 

491 requests_pb2.CreateHostRequestReq( 

492 host_user_id=user2.id, 

493 from_date=today_plus_2, 

494 to_date=today_plus_3, 

495 text=valid_request_text(), 

496 ) 

497 ).host_request_id 

498 

499 # Surfer (author) can see it with GetHostRequest 

500 with requests_session(token1) as api: 

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

502 assert res.host_request_id == host_request_id 

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

504 

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

506 with requests_session(token2) as api: 

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

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

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

510 

511 

512def test_unlisted_host_request_not_in_lists(db): 

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

514 user1, token1 = generate_user() 

515 user2, token2 = generate_user() 

516 

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

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

519 

520 with requests_session(token1) as api: 

521 host_request_id = api.CreateHostRequest( 

522 requests_pb2.CreateHostRequestReq( 

523 host_user_id=user2.id, 

524 from_date=today_plus_2, 

525 to_date=today_plus_3, 

526 text=valid_request_text(), 

527 ) 

528 ).host_request_id 

529 

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

531 with requests_session(token1) as api: 

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

533 assert len(res.host_requests) == 1 

534 

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

536 with requests_session(token2) as api: 

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

538 assert len(res.host_requests) == 0 

539 

540 

541def test_approved_host_request_in_lists_and_notifications(db, push_collector): 

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

543 user1, token1 = generate_user() 

544 user2, token2 = generate_user() 

545 mod, mod_token = generate_user(is_superuser=True) 

546 

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

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

549 

550 with requests_session(token1) as api: 

551 host_request_id = api.CreateHostRequest( 

552 requests_pb2.CreateHostRequestReq( 

553 host_user_id=user2.id, 

554 from_date=today_plus_2, 

555 to_date=today_plus_3, 

556 text=valid_request_text(), 

557 ) 

558 ).host_request_id 

559 

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

561 process_jobs() 

562 push_collector.assert_user_has_count(user2.id, 0) 

563 

564 # Get the moderation state ID 

565 state_id = None 

566 with session_scope() as session: 

567 host_request = session.execute( 

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

569 ).scalar_one() 

570 state_id = host_request.moderation_state_id 

571 

572 # Approve the host request via API 

573 with real_moderation_session(mod_token) as api: 

574 api.ModerateContent( 

575 moderation_pb2.ModerateContentReq( 

576 moderation_state_id=state_id, 

577 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

578 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

579 reason="Looks good", 

580 ) 

581 ) 

582 

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

584 process_jobs() 

585 

586 # Now surfer SHOULD see it in their sent list 

587 with requests_session(token1) as api: 

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

589 assert len(res.host_requests) == 1 

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

591 

592 # Host SHOULD see it in their received list 

593 with requests_session(token2) as api: 

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

595 assert len(res.host_requests) == 1 

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

597 

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

599 push_collector.assert_user_has_single_matching(user2.id, topic_action="host_request:create") 

600 

601 

602def test_hidden_host_request_invisible_to_all(db): 

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

604 user1, token1 = generate_user() 

605 user2, token2 = generate_user() 

606 user3, token3 = generate_user() # Third party 

607 moderator, moderator_token = generate_user(is_superuser=True) 

608 

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

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

611 

612 with requests_session(token1) as api: 

613 host_request_id = api.CreateHostRequest( 

614 requests_pb2.CreateHostRequestReq( 

615 host_user_id=user2.id, 

616 from_date=today_plus_2, 

617 to_date=today_plus_3, 

618 text=valid_request_text(), 

619 ) 

620 ).host_request_id 

621 

622 # Get the moderation state ID 

623 state_id = None 

624 with session_scope() as session: 

625 host_request = session.execute( 

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

627 ).scalar_one() 

628 state_id = host_request.moderation_state_id 

629 

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

631 with real_moderation_session(moderator_token) as api: 

632 api.ModerateContent( 

633 moderation_pb2.ModerateContentReq( 

634 moderation_state_id=state_id, 

635 action=moderation_pb2.MODERATION_ACTION_HIDE, 

636 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

637 reason="Spam content", 

638 ) 

639 ) 

640 

641 # Surfer can't see it with GetHostRequest 

642 with requests_session(token1) as api: 

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

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

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

646 

647 # Host can't see it with GetHostRequest 

648 with requests_session(token2) as api: 

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

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

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

652 

653 # Third party definitely can't see it 

654 with requests_session(token3) as api: 

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

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

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

658 

659 # Not in any lists 

660 with requests_session(token1) as api: 

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

662 assert len(res.host_requests) == 0 

663 

664 with requests_session(token2) as api: 

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

666 assert len(res.host_requests) == 0 

667 

668 

669def test_multiple_host_requests_listing_visibility(db): 

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

671 user1, token1 = generate_user() 

672 user2, token2 = generate_user() 

673 moderator, moderator_token = generate_user(is_superuser=True) 

674 

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

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

677 

678 # Create 3 host requests 

679 host_request_ids = [] 

680 state_ids = [] 

681 with requests_session(token1) as api: 

682 for i in range(3): 

683 hr_id = api.CreateHostRequest( 

684 requests_pb2.CreateHostRequestReq( 

685 host_user_id=user2.id, 

686 from_date=today_plus_2, 

687 to_date=today_plus_3, 

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

689 ) 

690 ).host_request_id 

691 host_request_ids.append(hr_id) 

692 

693 # Get state IDs 

694 with session_scope() as session: 

695 for hr_id in host_request_ids: 

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

697 state_ids.append(host_request.moderation_state_id) 

698 

699 # Approve the first one via API 

700 with real_moderation_session(moderator_token) as api: 

701 api.ModerateContent( 

702 moderation_pb2.ModerateContentReq( 

703 moderation_state_id=state_ids[0], 

704 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

705 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

706 reason="Approved", 

707 ) 

708 ) 

709 

710 # Hide the third one via API 

711 with real_moderation_session(moderator_token) as api: 

712 api.ModerateContent( 

713 moderation_pb2.ModerateContentReq( 

714 moderation_state_id=state_ids[2], 

715 action=moderation_pb2.MODERATION_ACTION_HIDE, 

716 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

717 reason="Spam", 

718 ) 

719 ) 

720 

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

722 with requests_session(token1) as api: 

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

724 assert len(res.host_requests) == 2 

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

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

727 

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

729 with requests_session(token2) as api: 

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

731 assert len(res.host_requests) == 1 

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

733 

734 

735def test_moderation_log_tracking(db): 

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

737 user, user_token = generate_user() 

738 host, _ = generate_user() 

739 moderator1, moderator1_token = generate_user(is_superuser=True) 

740 moderator2, moderator2_token = generate_user(is_superuser=True) 

741 

742 # Create a real host request 

743 state_id = create_test_host_request_with_moderation(user_token, host.id) 

744 

745 # Perform several moderation actions via API 

746 with real_moderation_session(moderator1_token) as api: 

747 api.ModerateContent( 

748 moderation_pb2.ModerateContentReq( 

749 moderation_state_id=state_id, 

750 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

751 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

752 reason="Looks good initially", 

753 ) 

754 ) 

755 

756 with real_moderation_session(moderator2_token) as api: 

757 api.FlagContentForReview( 

758 moderation_pb2.FlagContentForReviewReq( 

759 moderation_state_id=state_id, 

760 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

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

762 ) 

763 ) 

764 # Shadow it back 

765 api.ModerateContent( 

766 moderation_pb2.ModerateContentReq( 

767 moderation_state_id=state_id, 

768 action=moderation_pb2.MODERATION_ACTION_HIDE, 

769 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

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

771 ) 

772 ) 

773 

774 with real_moderation_session(moderator1_token) as api: 

775 api.ModerateContent( 

776 moderation_pb2.ModerateContentReq( 

777 moderation_state_id=state_id, 

778 action=moderation_pb2.MODERATION_ACTION_HIDE, 

779 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

780 reason="Actually it's spam", 

781 ) 

782 ) 

783 

784 # Check all log entries 

785 with session_scope() as session: 

786 log_entries = ( 

787 session.execute( 

788 select(ModerationLog) 

789 .where(ModerationLog.moderation_state_id == state_id) 

790 .order_by(ModerationLog.time.asc()) 

791 ) 

792 .scalars() 

793 .all() 

794 ) 

795 

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

797 assert len(log_entries) >= 3 

798 

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

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

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

802 

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

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

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

806 

807 # The last action should be hiding 

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

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

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

811 

812 

813def test_moderation_queue_workflow(db): 

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

815 user1, token1 = generate_user() 

816 user2, _ = generate_user() 

817 moderator, moderator_token = generate_user(is_superuser=True) 

818 

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

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

821 

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

823 with requests_session(token1) as api: 

824 host_request_id = api.CreateHostRequest( 

825 requests_pb2.CreateHostRequestReq( 

826 host_user_id=user2.id, 

827 from_date=today_plus_2, 

828 to_date=today_plus_3, 

829 text=valid_request_text(), 

830 ) 

831 ).host_request_id 

832 

833 state_id = None 

834 queue_item_id = None 

835 with session_scope() as session: 

836 # Get the host request and its moderation state 

837 host_request = session.execute( 

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

839 ).scalar_one() 

840 state_id = host_request.moderation_state_id 

841 

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

843 queue_item = session.execute( 

844 select(ModerationQueueItem) 

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

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

847 ).scalar_one() 

848 queue_item_id = queue_item.id 

849 

850 # Verify it's in the queue 

851 unresolved_items = ( 

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

853 .scalars() 

854 .all() 

855 ) 

856 

857 assert len(unresolved_items) >= 1 

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

859 

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

861 with real_moderation_session(moderator_token) as api: 

862 api.ModerateContent( 

863 moderation_pb2.ModerateContentReq( 

864 moderation_state_id=state_id, 

865 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

866 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

867 reason="Content approved", 

868 ) 

869 ) 

870 

871 # Verify queue item was resolved 

872 with session_scope() as session: 

873 # Verify it's no longer in unresolved queue 

874 unresolved_items = ( 

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

876 .scalars() 

877 .all() 

878 ) 

879 

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

881 

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

883 queue_item = session.get(ModerationQueueItem, queue_item_id) 

884 assert queue_item.resolved_by_log_id is not None 

885 

886 

887# ============================================================================ 

888# Moderation API Tests (testing the gRPC servicer) 

889# ============================================================================ 

890 

891 

892def test_GetModerationQueue_empty(db): 

893 """Test getting an empty moderation queue""" 

894 super_user, super_token = generate_user(is_superuser=True) 

895 

896 with real_moderation_session(super_token) as api: 

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

898 assert len(res.queue_items) == 0 

899 assert res.next_page_token == "" 

900 

901 

902def test_GetModerationQueue_with_items(db): 

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

904 super_user, super_token = generate_user(is_superuser=True) 

905 normal_user, user_token = generate_user() 

906 host, _ = generate_user() 

907 

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

909 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

910 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

911 

912 with real_moderation_session(super_token) as api: 

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

914 assert len(res.queue_items) == 2 

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

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

917 

918 

919def test_GetModerationQueue_filter_by_trigger(db): 

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

921 super_user, super_token = generate_user(is_superuser=True) 

922 normal_user, user_token = generate_user() 

923 host, _ = generate_user() 

924 

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

926 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

927 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

928 

929 # Add USER_FLAG trigger to second item via API 

930 with real_moderation_session(super_token) as api: 

931 api.FlagContentForReview( 

932 moderation_pb2.FlagContentForReviewReq( 

933 moderation_state_id=state2_id, 

934 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG, 

935 reason="Reported by user", 

936 ) 

937 ) 

938 

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

940 with real_moderation_session(super_token) as api: 

941 res = api.GetModerationQueue( 

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

943 ) 

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

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

946 

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

948 with real_moderation_session(super_token) as api: 

949 res = api.GetModerationQueue( 

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

951 ) 

952 assert len(res.queue_items) == 1 

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

954 

955 

956def test_GetModerationQueue_filter_created_before(db): 

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

958 super_user, super_token = generate_user(is_superuser=True) 

959 normal_user, user_token = generate_user() 

960 host, _ = generate_user() 

961 

962 # Create host requests 

963 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

964 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

965 

966 # Backdate the first queue item 

967 with session_scope() as session: 

968 queue_item1 = session.execute( 

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

970 ).scalar_one() 

971 # Set it to 2 hours ago 

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

973 

974 # The second item remains at current time 

975 

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

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

978 with real_moderation_session(super_token) as api: 

979 res = api.GetModerationQueue( 

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

981 ) 

982 assert len(res.queue_items) == 1 

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

984 

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

986 with real_moderation_session(super_token) as api: 

987 res = api.GetModerationQueue( 

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

989 ) 

990 assert len(res.queue_items) == 2 

991 

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

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

994 with real_moderation_session(super_token) as api: 

995 res = api.GetModerationQueue( 

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

997 ) 

998 assert len(res.queue_items) == 0 

999 

1000 

1001def test_GetModerationQueue_filter_created_after(db): 

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

1003 super_user, super_token = generate_user(is_superuser=True) 

1004 normal_user, user_token = generate_user() 

1005 host, _ = generate_user() 

1006 

1007 # Create host requests 

1008 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1009 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1010 

1011 # Backdate the first queue item to 2 hours ago 

1012 with session_scope() as session: 

1013 queue_item1 = session.execute( 

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

1015 ).scalar_one() 

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

1017 

1018 # The second item remains at current time 

1019 

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

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

1022 with real_moderation_session(super_token) as api: 

1023 res = api.GetModerationQueue( 

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

1025 ) 

1026 assert len(res.queue_items) == 1 

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

1028 

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

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

1031 with real_moderation_session(super_token) as api: 

1032 res = api.GetModerationQueue( 

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

1034 ) 

1035 assert len(res.queue_items) == 2 

1036 

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

1038 with real_moderation_session(super_token) as api: 

1039 res = api.GetModerationQueue( 

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

1041 ) 

1042 assert len(res.queue_items) == 0 

1043 

1044 

1045def test_GetModerationQueue_filter_created_before_and_after(db): 

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

1047 super_user, super_token = generate_user(is_superuser=True) 

1048 normal_user, user_token = generate_user() 

1049 host, _ = generate_user() 

1050 

1051 # Create 3 host requests 

1052 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1053 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1054 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1055 

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

1057 with session_scope() as session: 

1058 queue_item1 = session.execute( 

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

1060 ).scalar_one() 

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

1062 

1063 queue_item2 = session.execute( 

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

1065 ).scalar_one() 

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

1067 

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

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

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

1071 with real_moderation_session(super_token) as api: 

1072 res = api.GetModerationQueue( 

1073 moderation_pb2.GetModerationQueueReq( 

1074 created_after=Timestamp_from_datetime(after_cutoff), 

1075 created_before=Timestamp_from_datetime(before_cutoff), 

1076 ) 

1077 ) 

1078 assert len(res.queue_items) == 1 

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

1080 

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

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

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

1084 with real_moderation_session(super_token) as api: 

1085 res = api.GetModerationQueue( 

1086 moderation_pb2.GetModerationQueueReq( 

1087 created_after=Timestamp_from_datetime(after_cutoff), 

1088 created_before=Timestamp_from_datetime(before_cutoff), 

1089 ) 

1090 ) 

1091 assert len(res.queue_items) == 1 

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

1093 

1094 

1095def test_GetModerationQueue_filter_unresolved(db): 

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

1097 super_user, super_token = generate_user(is_superuser=True) 

1098 normal_user, user_token = generate_user() 

1099 host, _ = generate_user() 

1100 

1101 # Create 2 host requests 

1102 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1103 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1104 

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

1106 with real_moderation_session(super_token) as api: 

1107 api.ModerateContent( 

1108 moderation_pb2.ModerateContentReq( 

1109 moderation_state_id=state1_id, 

1110 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1111 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1112 reason="Approved", 

1113 ) 

1114 ) 

1115 

1116 # Get all items 

1117 with real_moderation_session(super_token) as api: 

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

1119 assert len(res.queue_items) == 2 

1120 

1121 # Get only unresolved items 

1122 with real_moderation_session(super_token) as api: 

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

1124 assert len(res.queue_items) == 1 

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

1126 

1127 

1128def test_GetModerationQueue_filter_by_author(db): 

1129 """Test filtering moderation queue by item_author_user_id""" 

1130 super_user, super_token = generate_user(is_superuser=True) 

1131 user1, token1 = generate_user() 

1132 user2, token2 = generate_user() 

1133 host_user, _ = generate_user() 

1134 

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

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

1137 

1138 # Create 2 host requests by user1 

1139 with requests_session(token1) as api: 

1140 hr1_id = api.CreateHostRequest( 

1141 requests_pb2.CreateHostRequestReq( 

1142 host_user_id=host_user.id, 

1143 from_date=today_plus_2, 

1144 to_date=today_plus_3, 

1145 text=valid_request_text(), 

1146 ) 

1147 ).host_request_id 

1148 

1149 hr2_id = api.CreateHostRequest( 

1150 requests_pb2.CreateHostRequestReq( 

1151 host_user_id=host_user.id, 

1152 from_date=today_plus_2, 

1153 to_date=today_plus_3, 

1154 text=valid_request_text(), 

1155 ) 

1156 ).host_request_id 

1157 

1158 # Create 1 host request by user2 

1159 with requests_session(token2) as api: 

1160 hr3_id = api.CreateHostRequest( 

1161 requests_pb2.CreateHostRequestReq( 

1162 host_user_id=host_user.id, 

1163 from_date=today_plus_2, 

1164 to_date=today_plus_3, 

1165 text=valid_request_text(), 

1166 ) 

1167 ).host_request_id 

1168 

1169 # Get moderation state IDs 

1170 state1_id, state2_id, state3_id = None, None, None 

1171 with session_scope() as session: 

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

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

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

1175 state1_id = hr1.moderation_state_id 

1176 state2_id = hr2.moderation_state_id 

1177 state3_id = hr3.moderation_state_id 

1178 

1179 # Get all items (should be 3) 

1180 with real_moderation_session(super_token) as api: 

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

1182 assert len(res.queue_items) == 3 

1183 

1184 # Filter by user1 (should get 2) 

1185 with real_moderation_session(super_token) as api: 

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

1187 assert len(res.queue_items) == 2 

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

1189 

1190 # Filter by user2 (should get 1) 

1191 with real_moderation_session(super_token) as api: 

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

1193 assert len(res.queue_items) == 1 

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

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

1196 

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

1198 with real_moderation_session(super_token) as api: 

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

1200 assert len(res.queue_items) == 0 

1201 

1202 

1203def test_GetModerationQueue_ordering(db): 

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

1205 super_user, super_token = generate_user(is_superuser=True) 

1206 normal_user, user_token = generate_user() 

1207 host, _ = generate_user() 

1208 

1209 # Create 3 host requests 

1210 state1_id = create_test_host_request_with_moderation(user_token, host.id) 

1211 state2_id = create_test_host_request_with_moderation(user_token, host.id) 

1212 state3_id = create_test_host_request_with_moderation(user_token, host.id) 

1213 

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

1215 with session_scope() as session: 

1216 queue_item1 = session.execute( 

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

1218 ).scalar_one() 

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

1220 

1221 queue_item2 = session.execute( 

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

1223 ).scalar_one() 

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

1225 

1226 queue_item3 = session.execute( 

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

1228 ).scalar_one() 

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

1230 

1231 # Default order (oldest first) 

1232 with real_moderation_session(super_token) as api: 

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

1234 assert len(res.queue_items) == 3 

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

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

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

1238 

1239 # Explicit oldest first 

1240 with real_moderation_session(super_token) as api: 

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

1242 assert len(res.queue_items) == 3 

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

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

1245 

1246 # Newest first 

1247 with real_moderation_session(super_token) as api: 

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

1249 assert len(res.queue_items) == 3 

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

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

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

1253 

1254 

1255def test_GetModerationQueue_pagination_newest_first(db): 

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

1257 super_user, super_token = generate_user(is_superuser=True) 

1258 normal_user, normal_token = generate_user() 

1259 host_user, _ = generate_user() 

1260 

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

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

1263 

1264 # Create 5 host requests 

1265 hr_ids = [] 

1266 with requests_session(normal_token) as api: 

1267 for i in range(5): 

1268 hr_id = api.CreateHostRequest( 

1269 requests_pb2.CreateHostRequestReq( 

1270 host_user_id=host_user.id, 

1271 from_date=today_plus_2, 

1272 to_date=today_plus_3, 

1273 text=valid_request_text(), 

1274 ) 

1275 ).host_request_id 

1276 hr_ids.append(hr_id) 

1277 

1278 # Get moderation state IDs 

1279 state_ids = [] 

1280 with session_scope() as session: 

1281 for hr_id in hr_ids: 

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

1283 state_ids.append(hr.moderation_state_id) 

1284 

1285 # Set different times so ordering is deterministic 

1286 with session_scope() as session: 

1287 for i, state_id in enumerate(state_ids): 

1288 queue_item = session.execute( 

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

1290 ).scalar_one() 

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

1292 

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

1294 with real_moderation_session(super_token) as api: 

1295 res1 = api.GetModerationQueue( 

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

1297 ) 

1298 assert len(res1.queue_items) == 2 

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

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

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

1302 assert res1.next_page_token # should have more pages 

1303 

1304 # Get second page using the token 

1305 res2 = api.GetModerationQueue( 

1306 moderation_pb2.GetModerationQueueReq( 

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

1308 ) 

1309 ) 

1310 assert len(res2.queue_items) == 2 

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

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

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

1314 

1315 # Pages should not overlap 

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

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

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

1319 

1320 

1321def test_GetModerationLog(db): 

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

1323 super_user, super_token = generate_user(is_superuser=True) 

1324 moderator, moderator_token = generate_user(is_superuser=True) 

1325 normal_user, user_token = generate_user() 

1326 host, _ = generate_user() 

1327 

1328 # Create a real host request 

1329 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1330 

1331 # Perform a moderation action via API 

1332 with real_moderation_session(moderator_token) as api: 

1333 api.ModerateContent( 

1334 moderation_pb2.ModerateContentReq( 

1335 moderation_state_id=state_id, 

1336 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1337 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1338 reason="Looks good", 

1339 ) 

1340 ) 

1341 

1342 with real_moderation_session(super_token) as api: 

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

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

1345 assert res.moderation_state.moderation_state_id == state_id 

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

1347 # Log entries are in reverse chronological order 

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

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

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

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

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

1353 

1354 

1355def test_GetModerationLog_not_found(db): 

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

1357 super_user, super_token = generate_user(is_superuser=True) 

1358 

1359 with real_moderation_session(super_token) as api: 

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

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

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

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

1364 

1365 

1366def test_GetModerationState(db): 

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

1368 super_user, super_token = generate_user(is_superuser=True) 

1369 user1, token1 = generate_user() 

1370 user2, _ = generate_user() 

1371 

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

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

1374 

1375 with requests_session(token1) as api: 

1376 host_request_id = api.CreateHostRequest( 

1377 requests_pb2.CreateHostRequestReq( 

1378 host_user_id=user2.id, 

1379 from_date=today_plus_2, 

1380 to_date=today_plus_3, 

1381 text=valid_request_text(), 

1382 ) 

1383 ).host_request_id 

1384 

1385 with real_moderation_session(super_token) as api: 

1386 res = api.GetModerationState( 

1387 moderation_pb2.GetModerationStateReq( 

1388 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1389 object_id=host_request_id, 

1390 ) 

1391 ) 

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

1393 assert res.moderation_state.object_id == host_request_id 

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

1395 assert res.moderation_state.moderation_state_id > 0 

1396 

1397 

1398def test_GetModerationState_not_found(db): 

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

1400 super_user, super_token = generate_user(is_superuser=True) 

1401 

1402 with real_moderation_session(super_token) as api: 

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

1404 api.GetModerationState( 

1405 moderation_pb2.GetModerationStateReq( 

1406 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1407 object_id=999999, 

1408 ) 

1409 ) 

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

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

1412 

1413 

1414def test_GetModerationState_unspecified_type(db): 

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

1416 super_user, super_token = generate_user(is_superuser=True) 

1417 

1418 with real_moderation_session(super_token) as api: 

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

1420 api.GetModerationState( 

1421 moderation_pb2.GetModerationStateReq( 

1422 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED, 

1423 object_id=123, 

1424 ) 

1425 ) 

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

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

1428 

1429 

1430def test_ModerateContent_approve(db): 

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

1432 super_user, super_token = generate_user(is_superuser=True) 

1433 user1, token1 = generate_user() 

1434 user2, _ = generate_user() 

1435 

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

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

1438 

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

1440 with requests_session(token1) as api: 

1441 host_request_id = api.CreateHostRequest( 

1442 requests_pb2.CreateHostRequestReq( 

1443 host_user_id=user2.id, 

1444 from_date=today_plus_2, 

1445 to_date=today_plus_3, 

1446 text=valid_request_text(), 

1447 ) 

1448 ).host_request_id 

1449 

1450 # Get the moderation state ID 

1451 state_id = None 

1452 with session_scope() as session: 

1453 host_request = session.execute( 

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

1455 ).scalar_one() 

1456 state_id = host_request.moderation_state_id 

1457 

1458 with real_moderation_session(super_token) as api: 

1459 res = api.ModerateContent( 

1460 moderation_pb2.ModerateContentReq( 

1461 moderation_state_id=state_id, 

1462 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1463 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1464 reason="Approved by admin", 

1465 ) 

1466 ) 

1467 assert res.moderation_state.moderation_state_id == state_id 

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

1469 

1470 # Verify state was updated in database 

1471 with session_scope() as session: 

1472 state = session.get(ModerationState, state_id) 

1473 assert state.visibility == ModerationVisibility.VISIBLE 

1474 

1475 

1476def test_ModerateContent_not_found(db): 

1477 """Test moderating non-existent content""" 

1478 super_user, super_token = generate_user(is_superuser=True) 

1479 

1480 with real_moderation_session(super_token) as api: 

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

1482 api.ModerateContent( 

1483 moderation_pb2.ModerateContentReq( 

1484 moderation_state_id=999999, 

1485 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1486 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1487 reason="Test", 

1488 ) 

1489 ) 

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

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

1492 

1493 

1494def test_ModerateContent_hide(db): 

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

1496 super_user, super_token = generate_user(is_superuser=True) 

1497 normal_user, user_token = generate_user() 

1498 host, _ = generate_user() 

1499 

1500 # Create a real host request 

1501 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1502 

1503 with real_moderation_session(super_token) as api: 

1504 res = api.ModerateContent( 

1505 moderation_pb2.ModerateContentReq( 

1506 moderation_state_id=state_id, 

1507 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1508 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

1509 reason="Spam content", 

1510 ) 

1511 ) 

1512 assert res.moderation_state.moderation_state_id == state_id 

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

1514 

1515 # Verify state was updated in database 

1516 with session_scope() as session: 

1517 state = session.get(ModerationState, state_id) 

1518 assert state.visibility == ModerationVisibility.HIDDEN 

1519 

1520 

1521def test_ModerateContent_shadow(db): 

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

1523 super_user, super_token = generate_user(is_superuser=True) 

1524 normal_user, user_token = generate_user() 

1525 host, _ = generate_user() 

1526 

1527 # Create a real host request 

1528 state_id = create_test_host_request_with_moderation(user_token, host.id) 

1529 

1530 with real_moderation_session(super_token) as api: 

1531 res = api.ModerateContent( 

1532 moderation_pb2.ModerateContentReq( 

1533 moderation_state_id=state_id, 

1534 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1535 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

1536 reason="Needs further review", 

1537 ) 

1538 ) 

1539 assert res.moderation_state.moderation_state_id == state_id 

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

1541 

1542 # Verify state was updated in database 

1543 with session_scope() as session: 

1544 state = session.get(ModerationState, state_id) 

1545 assert state.visibility == ModerationVisibility.SHADOWED 

1546 

1547 

1548def test_FlagContentForReview(db): 

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

1550 super_user, super_token = generate_user(is_superuser=True) 

1551 user1, token1 = generate_user() 

1552 user2, _ = generate_user() 

1553 

1554 # Create a host request 

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

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

1557 

1558 with requests_session(token1) as api: 

1559 host_request_id = api.CreateHostRequest( 

1560 requests_pb2.CreateHostRequestReq( 

1561 host_user_id=user2.id, 

1562 from_date=today_plus_2, 

1563 to_date=today_plus_3, 

1564 text=valid_request_text(), 

1565 ) 

1566 ).host_request_id 

1567 

1568 # Get the moderation state ID 

1569 with session_scope() as session: 

1570 host_request = session.execute( 

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

1572 ).scalar_one() 

1573 state_id = host_request.moderation_state_id 

1574 

1575 with real_moderation_session(super_token) as api: 

1576 res = api.FlagContentForReview( 

1577 moderation_pb2.FlagContentForReviewReq( 

1578 moderation_state_id=state_id, 

1579 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW, 

1580 reason="Admin flagged for additional review", 

1581 ) 

1582 ) 

1583 assert res.queue_item.moderation_state_id == state_id 

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

1585 assert res.queue_item.is_resolved == False 

1586 

1587 # Verify queue item was created in database 

1588 with session_scope() as session: 

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

1590 queue_item = ( 

1591 session.execute( 

1592 select(ModerationQueueItem) 

1593 .where(ModerationQueueItem.moderation_state_id == state_id) 

1594 .order_by(ModerationQueueItem.time_created.desc()) 

1595 ) 

1596 .scalars() 

1597 .first() 

1598 ) 

1599 assert queue_item.trigger == ModerationTrigger.MODERATOR_REVIEW 

1600 assert queue_item.resolved_by_log_id is None 

1601 

1602 

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

1604# Tests for group chat moderation 

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

1606 

1607 

1608def test_group_chat_created_with_moderation_state(db): 

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

1610 from couchers.models import GroupChat 

1611 from couchers.proto import conversations_pb2 

1612 from tests.test_fixtures import conversations_session, make_friends 

1613 

1614 user1, token1 = generate_user() 

1615 user2, _ = generate_user() 

1616 make_friends(user1, user2) 

1617 

1618 with conversations_session(token1) as api: 

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

1620 group_chat_id = res.group_chat_id 

1621 

1622 # Verify moderation state was created 

1623 with session_scope() as session: 

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

1625 

1626 assert group_chat.moderation_state_id is not None 

1627 assert group_chat.moderation_state is not None 

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

1629 assert group_chat.moderation_state.object_id == group_chat_id 

1630 # Group chats start as SHADOWED 

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

1632 

1633 # A moderation queue item should have been created 

1634 queue_item = ( 

1635 session.execute( 

1636 select(ModerationQueueItem).where( 

1637 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id 

1638 ) 

1639 ) 

1640 .scalars() 

1641 .first() 

1642 ) 

1643 assert queue_item is not None 

1644 assert queue_item.trigger == ModerationTrigger.INITIAL_REVIEW 

1645 

1646 

1647def test_group_chat_GetModerationState(db): 

1648 """Test GetModerationState API for group chats""" 

1649 from couchers.proto import conversations_pb2 

1650 from tests.test_fixtures import conversations_session, make_friends 

1651 

1652 user1, token1 = generate_user() 

1653 user2, _ = generate_user() 

1654 moderator, mod_token = generate_user(is_superuser=True) 

1655 make_friends(user1, user2) 

1656 

1657 with conversations_session(token1) as api: 

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

1659 group_chat_id = res.group_chat_id 

1660 

1661 # Moderator can look up the moderation state 

1662 with real_moderation_session(mod_token) as api: 

1663 res = api.GetModerationState( 

1664 moderation_pb2.GetModerationStateReq( 

1665 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1666 object_id=group_chat_id, 

1667 ) 

1668 ) 

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

1670 assert res.moderation_state.object_id == group_chat_id 

1671 # Starts as SHADOWED 

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

1673 

1674 

1675def test_group_chat_moderation_hide(db): 

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

1677 from couchers.proto import conversations_pb2 

1678 from tests.test_fixtures import conversations_session, make_friends 

1679 

1680 user1, token1 = generate_user() 

1681 user2, token2 = generate_user() 

1682 moderator, mod_token = generate_user(is_superuser=True) 

1683 make_friends(user1, user2) 

1684 

1685 with conversations_session(token1) as api: 

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

1687 group_chat_id = res.group_chat_id 

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

1689 

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

1691 with real_moderation_session(mod_token) as api: 

1692 state_res = api.GetModerationState( 

1693 moderation_pb2.GetModerationStateReq( 

1694 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1695 object_id=group_chat_id, 

1696 ) 

1697 ) 

1698 api.ModerateContent( 

1699 moderation_pb2.ModerateContentReq( 

1700 moderation_state_id=state_res.moderation_state.moderation_state_id, 

1701 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

1702 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

1703 reason="Approved", 

1704 ) 

1705 ) 

1706 

1707 # Both users can see the chat now 

1708 with conversations_session(token1) as api: 

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

1710 assert len(res.group_chats) == 1 

1711 

1712 with conversations_session(token2) as api: 

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

1714 assert len(res.group_chats) == 1 

1715 

1716 # Moderator hides the group chat 

1717 with real_moderation_session(mod_token) as api: 

1718 state_res = api.GetModerationState( 

1719 moderation_pb2.GetModerationStateReq( 

1720 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1721 object_id=group_chat_id, 

1722 ) 

1723 ) 

1724 api.ModerateContent( 

1725 moderation_pb2.ModerateContentReq( 

1726 moderation_state_id=state_res.moderation_state.moderation_state_id, 

1727 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1728 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN, 

1729 reason="Inappropriate content", 

1730 ) 

1731 ) 

1732 

1733 # Neither user can see the chat now 

1734 with conversations_session(token1) as api: 

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

1736 assert len(res.group_chats) == 0 

1737 

1738 with conversations_session(token2) as api: 

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

1740 assert len(res.group_chats) == 0 

1741 

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

1743 with conversations_session(token1) as api: 

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

1745 assert len(res.messages) == 0 

1746 

1747 

1748def test_group_chat_moderation_shadow(db): 

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

1750 from couchers.proto import conversations_pb2 

1751 from tests.test_fixtures import conversations_session, make_friends 

1752 

1753 user1, token1 = generate_user() # Creator 

1754 user2, token2 = generate_user() # Participant 

1755 moderator, mod_token = generate_user(is_superuser=True) 

1756 make_friends(user1, user2) 

1757 

1758 with conversations_session(token1) as api: 

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

1760 group_chat_id = res.group_chat_id 

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

1762 

1763 # Moderator shadows the group chat 

1764 with real_moderation_session(mod_token) as api: 

1765 state_res = api.GetModerationState( 

1766 moderation_pb2.GetModerationStateReq( 

1767 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT, 

1768 object_id=group_chat_id, 

1769 ) 

1770 ) 

1771 api.ModerateContent( 

1772 moderation_pb2.ModerateContentReq( 

1773 moderation_state_id=state_res.moderation_state.moderation_state_id, 

1774 action=moderation_pb2.MODERATION_ACTION_HIDE, 

1775 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

1776 reason="Needs review", 

1777 ) 

1778 ) 

1779 

1780 # Creator can see SHADOWED content in list operations 

1781 with conversations_session(token1) as api: 

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

1783 assert len(res.group_chats) == 1 

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

1785 

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

1787 with conversations_session(token2) as api: 

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

1789 assert len(res.group_chats) == 0 

1790 

1791 # Creator can also access it directly via GetGroupChat 

1792 with conversations_session(token1) as api: 

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

1794 assert res.group_chat_id == group_chat_id 

1795 

1796 

1797# ============================================================================ 

1798# Tests for auto-approval background job 

1799# ============================================================================ 

1800 

1801 

1802def test_auto_approve_moderation_queue_disabled_when_zero(db): 

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

1804 moderator, mod_token = generate_user(is_superuser=True) 

1805 user1, token1 = generate_user() 

1806 user2, token2 = generate_user() 

1807 

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

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

1810 

1811 # Create a host request 

1812 with requests_session(token1) as api: 

1813 with mock_notification_email() as mock: 

1814 host_request_id = api.CreateHostRequest( 

1815 requests_pb2.CreateHostRequestReq( 

1816 host_user_id=user2.id, 

1817 from_date=today_plus_2, 

1818 to_date=today_plus_3, 

1819 text=valid_request_text(), 

1820 ) 

1821 ).host_request_id 

1822 

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

1824 mock.assert_not_called() 

1825 

1826 # Ensure deadline is 0 (disabled) 

1827 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 0 

1828 

1829 # Run the job 

1830 auto_approve_moderation_queue(empty_pb2.Empty()) 

1831 

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

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

1834 assert res.host_request_id == host_request_id 

1835 

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

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

1838 assert len(res.host_requests) == 1 

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

1840 

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

1842 with requests_session(token2) as api: 

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

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

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

1846 

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

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

1849 assert len(res.host_requests) == 0 

1850 

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

1852 with real_moderation_session(mod_token) as api: 

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

1854 assert len(res.queue_items) == 1 

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

1856 

1857 # Moderator can check the state is still SHADOWED 

1858 state_res = api.GetModerationState( 

1859 moderation_pb2.GetModerationStateReq( 

1860 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1861 object_id=host_request_id, 

1862 ) 

1863 ) 

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

1865 

1866 

1867def test_auto_approve_moderation_queue_approves_old_items(db, push_collector): 

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

1869 moderator, mod_token = generate_user(is_superuser=True) 

1870 user1, token1 = generate_user() 

1871 user2, token2 = generate_user() 

1872 

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

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

1875 

1876 # Create a host request 

1877 with requests_session(token1) as api: 

1878 with mock_notification_email() as mock: 

1879 host_request_id = api.CreateHostRequest( 

1880 requests_pb2.CreateHostRequestReq( 

1881 host_user_id=user2.id, 

1882 from_date=today_plus_2, 

1883 to_date=today_plus_3, 

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

1885 ) 

1886 ).host_request_id 

1887 

1888 # No email sent initially (shadowed) 

1889 mock.assert_not_called() 

1890 

1891 # Host cannot see the request yet 

1892 with requests_session(token2) as api: 

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

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

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

1896 

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

1898 with session_scope() as session: 

1899 host_request = session.execute( 

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

1901 ).scalar_one() 

1902 queue_item = session.execute( 

1903 select(ModerationQueueItem) 

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

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

1906 ).scalar_one() 

1907 # Backdate the queue item by 2 minutes 

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

1909 

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

1911 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 60 

1912 config["MODERATION_BOT_USER_ID"] = moderator.id 

1913 

1914 # Run the job 

1915 auto_approve_moderation_queue(empty_pb2.Empty()) 

1916 

1917 # Now host can see the request via API 

1918 with requests_session(token2) as api: 

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

1920 assert res.host_request_id == host_request_id 

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

1922 

1923 # Host sees it in their received list 

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

1925 assert len(res.host_requests) == 1 

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

1927 

1928 # Surfer sees it in their sent list 

1929 with requests_session(token1) as api: 

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

1931 assert len(res.host_requests) == 1 

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

1933 

1934 # Moderator sees the queue item is now resolved 

1935 with real_moderation_session(mod_token) as api: 

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

1937 assert len(res.queue_items) == 0 

1938 

1939 # State is now VISIBLE 

1940 state_res = api.GetModerationState( 

1941 moderation_pb2.GetModerationStateReq( 

1942 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

1943 object_id=host_request_id, 

1944 ) 

1945 ) 

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

1947 

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

1949 log_res = api.GetModerationLog( 

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

1951 ) 

1952 # Find the APPROVE action 

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

1954 assert len(approve_entries) == 1 

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

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

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

1958 

1959 

1960def test_auto_approve_does_not_approve_recent_items(db): 

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

1962 moderator, mod_token = generate_user(is_superuser=True) 

1963 user1, token1 = generate_user() 

1964 user2, token2 = generate_user() 

1965 

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

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

1968 

1969 # Create a host request 

1970 with requests_session(token1) as api: 

1971 with mock_notification_email() as mock: 

1972 host_request_id = api.CreateHostRequest( 

1973 requests_pb2.CreateHostRequestReq( 

1974 host_user_id=user2.id, 

1975 from_date=today_plus_2, 

1976 to_date=today_plus_3, 

1977 text=valid_request_text(), 

1978 ) 

1979 ).host_request_id 

1980 

1981 # No email sent (shadowed) 

1982 mock.assert_not_called() 

1983 

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

1985 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 3600 

1986 config["MODERATION_BOT_USER_ID"] = moderator.id 

1987 

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

1989 with mock_notification_email() as mock: 

1990 auto_approve_moderation_queue(empty_pb2.Empty()) 

1991 

1992 # Still no email sent 

1993 mock.assert_not_called() 

1994 

1995 # Host still cannot see the request 

1996 with requests_session(token2) as api: 

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

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

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

2000 

2001 # Not in host's received list 

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

2003 assert len(res.host_requests) == 0 

2004 

2005 # Moderator sees it still in queue unresolved 

2006 with real_moderation_session(mod_token) as api: 

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

2008 assert len(res.queue_items) == 1 

2009 

2010 # State is still SHADOWED 

2011 state_res = api.GetModerationState( 

2012 moderation_pb2.GetModerationStateReq( 

2013 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2014 object_id=host_request_id, 

2015 ) 

2016 ) 

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

2018 

2019 

2020def test_auto_approve_does_not_approve_already_approved(db): 

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

2022 moderator, mod_token = generate_user(is_superuser=True) 

2023 user1, token1 = generate_user() 

2024 user2, token2 = generate_user() 

2025 

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

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

2028 

2029 # Create a host request 

2030 with requests_session(token1) as api: 

2031 host_request_id = api.CreateHostRequest( 

2032 requests_pb2.CreateHostRequestReq( 

2033 host_user_id=user2.id, 

2034 from_date=today_plus_2, 

2035 to_date=today_plus_3, 

2036 text=valid_request_text(), 

2037 ) 

2038 ).host_request_id 

2039 

2040 # Moderator approves it manually 

2041 with real_moderation_session(mod_token) as api: 

2042 state_res = api.GetModerationState( 

2043 moderation_pb2.GetModerationStateReq( 

2044 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2045 object_id=host_request_id, 

2046 ) 

2047 ) 

2048 state_id = state_res.moderation_state.moderation_state_id 

2049 

2050 api.ModerateContent( 

2051 moderation_pb2.ModerateContentReq( 

2052 moderation_state_id=state_id, 

2053 action=moderation_pb2.MODERATION_ACTION_APPROVE, 

2054 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE, 

2055 reason="Approved by moderator", 

2056 ) 

2057 ) 

2058 

2059 # Host can now see it 

2060 with requests_session(token2) as api: 

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

2062 assert res.host_request_id == host_request_id 

2063 

2064 # Get log count before auto-approval 

2065 with real_moderation_session(mod_token) as api: 

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

2067 log_count_before = len(log_res_before.log_entries) 

2068 

2069 # Set deadline to 1 second 

2070 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1 

2071 config["MODERATION_BOT_USER_ID"] = moderator.id 

2072 

2073 # Run the job 

2074 auto_approve_moderation_queue(empty_pb2.Empty()) 

2075 

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

2077 with real_moderation_session(mod_token) as api: 

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

2079 assert len(log_res_after.log_entries) == log_count_before 

2080 

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

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

2083 assert len(queue_res.queue_items) == 0 

2084 

2085 

2086def test_auto_approve_does_not_approve_moderator_shadowed_items(db): 

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

2088 moderator, mod_token = generate_user(is_superuser=True) 

2089 user1, token1 = generate_user() 

2090 user2, token2 = generate_user() 

2091 

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

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

2094 

2095 # Create a host request 

2096 with requests_session(token1) as api: 

2097 host_request_id = api.CreateHostRequest( 

2098 requests_pb2.CreateHostRequestReq( 

2099 host_user_id=user2.id, 

2100 from_date=today_plus_2, 

2101 to_date=today_plus_3, 

2102 text=valid_request_text(), 

2103 ) 

2104 ).host_request_id 

2105 

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

2107 with real_moderation_session(mod_token) as api: 

2108 state_res = api.GetModerationState( 

2109 moderation_pb2.GetModerationStateReq( 

2110 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2111 object_id=host_request_id, 

2112 ) 

2113 ) 

2114 state_id = state_res.moderation_state.moderation_state_id 

2115 

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

2117 api.ModerateContent( 

2118 moderation_pb2.ModerateContentReq( 

2119 moderation_state_id=state_id, 

2120 action=moderation_pb2.MODERATION_ACTION_HIDE, 

2121 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED, 

2122 reason="Keeping shadowed for review", 

2123 ) 

2124 ) 

2125 

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

2127 with session_scope() as session: 

2128 queue_item = session.execute( 

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

2130 ).scalar_one() 

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

2132 

2133 # Set deadline to 1 second 

2134 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1 

2135 config["MODERATION_BOT_USER_ID"] = moderator.id 

2136 

2137 # Get log count before 

2138 with real_moderation_session(mod_token) as api: 

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

2140 log_count_before = len(log_res_before.log_entries) 

2141 

2142 # Run the job 

2143 auto_approve_moderation_queue(empty_pb2.Empty()) 

2144 

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

2146 with real_moderation_session(mod_token) as api: 

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

2148 assert len(log_res_after.log_entries) == log_count_before 

2149 

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

2151 state_res = api.GetModerationState( 

2152 moderation_pb2.GetModerationStateReq( 

2153 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST, 

2154 object_id=host_request_id, 

2155 ) 

2156 ) 

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

2158 

2159 # Host still cannot see the request 

2160 with requests_session(token2) as api: 

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

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

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

2164 

2165 

2166# ============================================================================ 

2167# Notification Suppression Tests 

2168# ============================================================================ 

2169 

2170 

2171def test_host_request_message_notifications_suppressed_before_approval(db, push_collector, moderator): 

2172 """ 

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

2174 that haven't been approved yet. 

2175 """ 

2176 host, host_token = generate_user(complete_profile=True) 

2177 surfer, surfer_token = generate_user(complete_profile=True) 

2178 

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

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

2181 

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

2183 with requests_session(surfer_token) as api: 

2184 hr_id = api.CreateHostRequest( 

2185 requests_pb2.CreateHostRequestReq( 

2186 host_user_id=host.id, 

2187 from_date=today_plus_2, 

2188 to_date=today_plus_3, 

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

2190 ) 

2191 ).host_request_id 

2192 

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

2194 push_collector.assert_user_has_count(host.id, 0) 

2195 

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

2197 with requests_session(surfer_token) as api: 

2198 api.SendHostRequestMessage( 

2199 requests_pb2.SendHostRequestMessageReq( 

2200 host_request_id=hr_id, 

2201 text="Follow-up message 1", 

2202 ) 

2203 ) 

2204 api.SendHostRequestMessage( 

2205 requests_pb2.SendHostRequestMessageReq( 

2206 host_request_id=hr_id, 

2207 text="Follow-up message 2", 

2208 ) 

2209 ) 

2210 

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

2212 push_collector.assert_user_has_count(host.id, 0) 

2213 

2214 # Now approve the request 

2215 with mock_notification_email(): 

2216 moderator.approve_host_request(hr_id) 

2217 

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

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

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

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

2222 push_collector.assert_user_has_count(host.id, 3) 

2223 push_collector.assert_user_push_matches_fields( 

2224 host.id, 

2225 ix=0, 

2226 title=f"{surfer.name} sent you a host request", 

2227 ) 

2228 

2229 

2230def test_host_request_status_notifications_suppressed_before_approval(db, push_collector, moderator): 

2231 """ 

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

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

2234 

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

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

2237 """ 

2238 host, host_token = generate_user(complete_profile=True) 

2239 surfer, surfer_token = generate_user(complete_profile=True) 

2240 

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

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

2243 

2244 # Create host request 

2245 with requests_session(surfer_token) as api: 

2246 hr_id = api.CreateHostRequest( 

2247 requests_pb2.CreateHostRequestReq( 

2248 host_user_id=host.id, 

2249 from_date=today_plus_2, 

2250 to_date=today_plus_3, 

2251 text=valid_request_text(), 

2252 ) 

2253 ).host_request_id 

2254 

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

2256 push_collector.assert_user_has_count(host.id, 0) 

2257 

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

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

2260 with requests_session(surfer_token) as api: 

2261 api.RespondHostRequest( 

2262 requests_pb2.RespondHostRequestReq( 

2263 host_request_id=hr_id, 

2264 status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED, 

2265 text="Actually, never mind", 

2266 ) 

2267 ) 

2268 

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

2270 push_collector.assert_user_has_count(host.id, 0) 

2271 

2272 

2273def test_host_request_notifications_sent_after_approval(db, push_collector, moderator): 

2274 """ 

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

2276 """ 

2277 host, host_token = generate_user(complete_profile=True) 

2278 surfer, surfer_token = generate_user(complete_profile=True) 

2279 

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

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

2282 

2283 # Create and approve host request 

2284 with requests_session(surfer_token) as api: 

2285 hr_id = api.CreateHostRequest( 

2286 requests_pb2.CreateHostRequestReq( 

2287 host_user_id=host.id, 

2288 from_date=today_plus_2, 

2289 to_date=today_plus_3, 

2290 text=valid_request_text(), 

2291 ) 

2292 ).host_request_id 

2293 

2294 with mock_notification_email(): 

2295 moderator.approve_host_request(hr_id) 

2296 

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

2298 push_collector.assert_user_has_count(host.id, 1) 

2299 

2300 # Host accepts the request - surfer should be notified 

2301 with requests_session(host_token) as api: 

2302 with mock_notification_email(): 

2303 api.RespondHostRequest( 

2304 requests_pb2.RespondHostRequestReq( 

2305 host_request_id=hr_id, 

2306 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

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

2308 ) 

2309 ) 

2310 

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

2312 push_collector.assert_user_has_count(surfer.id, 1) 

2313 push_collector.assert_user_push_matches_fields( 

2314 surfer.id, 

2315 ix=0, 

2316 title=f"{host.name} accepted your host request", 

2317 ) 

2318 

2319 # Surfer confirms - host should be notified 

2320 with requests_session(surfer_token) as api: 

2321 with mock_notification_email(): 

2322 api.RespondHostRequest( 

2323 requests_pb2.RespondHostRequestReq( 

2324 host_request_id=hr_id, 

2325 status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED, 

2326 text="See you then!", 

2327 ) 

2328 ) 

2329 

2330 # Host should now have 2 notifications (approval + confirm) 

2331 push_collector.assert_user_has_count(host.id, 2) 

2332 push_collector.assert_user_push_matches_fields( 

2333 host.id, 

2334 ix=1, 

2335 title=f"{surfer.name} confirmed their host request", 

2336 ) 

2337 

2338 

2339def test_group_chat_message_notifications_suppressed_before_approval(db, push_collector, moderator): 

2340 """ 

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

2342 that haven't been approved yet. 

2343 """ 

2344 from couchers.jobs.worker import process_job 

2345 from couchers.models import GroupChat 

2346 

2347 user1, token1 = generate_user(complete_profile=True) 

2348 user2, token2 = generate_user(complete_profile=True) 

2349 

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

2351 with conversations_session(token1) as api: 

2352 res = api.CreateGroupChat( 

2353 conversations_pb2.CreateGroupChatReq( 

2354 recipient_user_ids=[user2.id], 

2355 ) 

2356 ) 

2357 gc_id = res.group_chat_id 

2358 

2359 # Verify initial state 

2360 with session_scope() as session: 

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

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

2363 

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

2365 push_collector.assert_user_has_count(user2.id, 0) 

2366 

2367 # Send messages BEFORE approval 

2368 with conversations_session(token1) as api: 

2369 api.SendMessage( 

2370 conversations_pb2.SendMessageReq( 

2371 group_chat_id=gc_id, 

2372 text="Hello before approval", 

2373 ) 

2374 ) 

2375 

2376 # Process the queued notification job 

2377 while process_job(): 

2378 pass 

2379 

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

2381 push_collector.assert_user_has_count(user2.id, 0) 

2382 

2383 # Now approve the group chat 

2384 moderator.approve_group_chat(gc_id) 

2385 

2386 # Process the queued notification jobs from approval 

2387 while process_job(): 

2388 pass 

2389 

2390 # Verify moderation state after approval 

2391 with session_scope() as session: 

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

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

2394 

2395 # User2 should now have 1 notification for the first message sent before approval 

2396 push_collector.assert_user_has_single_matching( 

2397 user2.id, 

2398 title=f"{user1.name} sent you a message", 

2399 body="Hello before approval", 

2400 ) 

2401 

2402 # Send a message AFTER approval 

2403 with conversations_session(token1) as api: 

2404 api.SendMessage( 

2405 conversations_pb2.SendMessageReq( 

2406 group_chat_id=gc_id, 

2407 text="Hello after approval", 

2408 ) 

2409 ) 

2410 

2411 # Process the queued notification job 

2412 while process_job(): 

2413 pass 

2414 

2415 # User2 SHOULD now have 2 notifications total 

2416 push_collector.assert_user_has_count(user2.id, 2)