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
« 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"""
5from datetime import datetime, timedelta
7import grpc
8import pytest
9from google.protobuf import empty_pb2
10from sqlalchemy.sql import select
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
45@pytest.fixture(autouse=True)
46def _(testconfig):
47 pass
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()
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
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
70# ============================================================================
71# Tests for moderation helper functions
72# ============================================================================
75def test_create_moderation(db):
76 """Test creating a moderation state with associated log entry"""
77 user, _ = generate_user()
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 )
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
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 )
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
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()
112 today_plus_2 = (today() + timedelta(days=2)).isoformat()
113 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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 )
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
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()
157 today_plus_2 = (today() + timedelta(days=2)).isoformat()
158 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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 )
187 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
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
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 )
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"
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)
217 today_plus_2 = (today() + timedelta(days=2)).isoformat()
218 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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()
246 assert queue_item.resolved_by_log_id is None
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 )
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
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)
275 today_plus_2 = (today() + timedelta(days=2)).isoformat()
276 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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 )
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
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()
320 assert log_entry.moderator_user_id == moderator.id
321 assert log_entry.reason == "Quick approval"
324# ============================================================================
325# Tests for host request moderation integration
326# ============================================================================
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()
334 today_plus_2 = (today() + timedelta(days=2)).isoformat()
335 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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()
353 assert host_request.moderation_state_id is not None
355 # Check moderation state properties
356 moderation_state = session.execute(
357 select(ModerationState).where(ModerationState.id == host_request.moderation_state_id)
358 ).scalar_one()
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
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 )
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
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()
385 today_plus_2 = (today() + timedelta(days=2)).isoformat()
386 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
398 # Process all jobs (including the notification job)
399 process_jobs()
401 # No push notification should be sent yet (host requests are shadowed initially)
402 push_collector.assert_user_has_count(user2.id, 0)
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()
410 today_plus_2 = (today() + timedelta(days=2)).isoformat()
411 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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)
437 today_plus_2 = (today() + timedelta(days=2)).isoformat()
438 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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
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 )
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"
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()
486 today_plus_2 = (today() + timedelta(days=2)).isoformat()
487 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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()
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
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()
517 today_plus_2 = (today() + timedelta(days=2)).isoformat()
518 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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
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)
547 today_plus_2 = (today() + timedelta(days=2)).isoformat()
548 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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)
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
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 )
583 # Process the re-queued notification job - should now send notification
584 process_jobs()
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
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
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")
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)
609 today_plus_2 = (today() + timedelta(days=2)).isoformat()
610 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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 )
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
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
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
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
664 with requests_session(token2) as api:
665 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
666 assert len(res.host_requests) == 0
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)
675 today_plus_2 = (today() + timedelta(days=2)).isoformat()
676 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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)
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)
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 )
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 )
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]}
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]
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)
742 # Create a real host request
743 state_id = create_test_host_request_with_moderation(user_token, host.id)
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 )
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 )
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 )
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 )
796 # CREATE + APPROVE + HIDE + HIDE (shadowing back counts as HIDE action)
797 assert len(log_entries) >= 3
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."
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"
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"
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)
819 today_plus_2 = (today() + timedelta(days=2)).isoformat()
820 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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
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 )
857 assert len(unresolved_items) >= 1
858 assert queue_item.id in [item.id for item in unresolved_items]
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 )
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 )
880 assert queue_item_id not in [item.id for item in unresolved_items]
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
887# ============================================================================
888# Moderation API Tests (testing the gRPC servicer)
889# ============================================================================
892def test_GetModerationQueue_empty(db):
893 """Test getting an empty moderation queue"""
894 super_user, super_token = generate_user(is_superuser=True)
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 == ""
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()
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)
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
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()
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)
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 )
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)
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
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()
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)
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)
974 # The second item remains at current time
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
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
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
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()
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)
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)
1018 # The second item remains at current time
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
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
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
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()
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)
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)
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)
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
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
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()
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)
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 )
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
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
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()
1135 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1136 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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
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
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
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)
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
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
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()
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)
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)
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)
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)
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
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
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
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()
1261 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1262 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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)
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)
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
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
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]
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"
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()
1328 # Create a real host request
1329 state_id = create_test_host_request_with_moderation(user_token, host.id)
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 )
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
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)
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."
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()
1372 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1373 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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)
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."
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)
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."
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()
1436 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1437 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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
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
1476def test_ModerateContent_not_found(db):
1477 """Test moderating non-existent content"""
1478 super_user, super_token = generate_user(is_superuser=True)
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."
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()
1500 # Create a real host request
1501 state_id = create_test_host_request_with_moderation(user_token, host.id)
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
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
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()
1527 # Create a real host request
1528 state_id = create_test_host_request_with_moderation(user_token, host.id)
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
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
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()
1554 # Create a host request
1555 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1556 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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
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
1603# ============================================================================
1604# Tests for group chat moderation
1605# ============================================================================
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
1614 user1, token1 = generate_user()
1615 user2, _ = generate_user()
1616 make_friends(user1, user2)
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
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()
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
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
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
1652 user1, token1 = generate_user()
1653 user2, _ = generate_user()
1654 moderator, mod_token = generate_user(is_superuser=True)
1655 make_friends(user1, user2)
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
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
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
1680 user1, token1 = generate_user()
1681 user2, token2 = generate_user()
1682 moderator, mod_token = generate_user(is_superuser=True)
1683 make_friends(user1, user2)
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!"))
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 )
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
1712 with conversations_session(token2) as api:
1713 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1714 assert len(res.group_chats) == 1
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 )
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
1738 with conversations_session(token2) as api:
1739 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1740 assert len(res.group_chats) == 0
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
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
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)
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!"))
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 )
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
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
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
1797# ============================================================================
1798# Tests for auto-approval background job
1799# ============================================================================
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()
1808 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1809 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
1823 # No email should have been sent (request is shadowed)
1824 mock.assert_not_called()
1826 # Ensure deadline is 0 (disabled)
1827 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 0
1829 # Run the job
1830 auto_approve_moderation_queue(empty_pb2.Empty())
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
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
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
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
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
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
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()
1873 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1874 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
1888 # No email sent initially (shadowed)
1889 mock.assert_not_called()
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
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)
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
1914 # Run the job
1915 auto_approve_moderation_queue(empty_pb2.Empty())
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")
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
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
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
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
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
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()
1966 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1967 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
1981 # No email sent (shadowed)
1982 mock.assert_not_called()
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
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())
1992 # Still no email sent
1993 mock.assert_not_called()
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
2001 # Not in host's received list
2002 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2003 assert len(res.host_requests) == 0
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
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
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()
2026 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2027 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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 )
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
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)
2069 # Set deadline to 1 second
2070 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1
2071 config["MODERATION_BOT_USER_ID"] = moderator.id
2073 # Run the job
2074 auto_approve_moderation_queue(empty_pb2.Empty())
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
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
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()
2092 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2093 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
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
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 )
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)
2133 # Set deadline to 1 second
2134 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1
2135 config["MODERATION_BOT_USER_ID"] = moderator.id
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)
2142 # Run the job
2143 auto_approve_moderation_queue(empty_pb2.Empty())
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
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
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
2166# ============================================================================
2167# Notification Suppression Tests
2168# ============================================================================
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)
2179 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2180 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
2193 # No notifications should have been sent to the host (request is SHADOWED)
2194 push_collector.assert_user_has_count(host.id, 0)
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 )
2211 # Host should STILL have no notifications (messages sent while SHADOWED)
2212 push_collector.assert_user_has_count(host.id, 0)
2214 # Now approve the request
2215 with mock_notification_email():
2216 moderator.approve_host_request(hr_id)
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 )
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.
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)
2241 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2242 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
2255 # No notifications should have been sent to the host (request is SHADOWED)
2256 push_collector.assert_user_has_count(host.id, 0)
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 )
2269 # Host should STILL have no notifications (cancel notification suppressed)
2270 push_collector.assert_user_has_count(host.id, 0)
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)
2280 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2281 today_plus_3 = (today() + timedelta(days=3)).isoformat()
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
2294 with mock_notification_email():
2295 moderator.approve_host_request(hr_id)
2297 # Host should have received 1 notification (the approval notification)
2298 push_collector.assert_user_has_count(host.id, 1)
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 )
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 )
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 )
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 )
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
2347 user1, token1 = generate_user(complete_profile=True)
2348 user2, token2 = generate_user(complete_profile=True)
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
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
2364 # No notifications should have been sent yet (chat is SHADOWED)
2365 push_collector.assert_user_has_count(user2.id, 0)
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 )
2376 # Process the queued notification job
2377 while process_job():
2378 pass
2380 # User2 should STILL have no notifications (chat is SHADOWED)
2381 push_collector.assert_user_has_count(user2.id, 0)
2383 # Now approve the group chat
2384 moderator.approve_group_chat(gc_id)
2386 # Process the queued notification jobs from approval
2387 while process_job():
2388 pass
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
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 )
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 )
2411 # Process the queued notification job
2412 while process_job():
2413 pass
2415 # User2 SHOULD now have 2 notifications total
2416 push_collector.assert_user_has_count(user2.id, 2)