Coverage for app/backend/src/tests/test_moderation.py: 100%
1440 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +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.constants import MODERATION_AUTO_APPROVE_FLAG_PRIORITY
14from couchers.db import session_scope
15from couchers.jobs.handlers import auto_approve_moderation_queue
16from couchers.jobs.worker import process_job
17from couchers.models import (
18 AdminAction,
19 EventOccurrence,
20 FriendRelationship,
21 GroupChat,
22 HostRequest,
23 ModerationAction,
24 ModerationLog,
25 ModerationObjectType,
26 ModerationQueueItem,
27 ModerationState,
28 ModerationTrigger,
29 ModerationVisibility,
30 User,
31)
32from couchers.moderation.utils import create_moderation
33from couchers.proto import (
34 api_pb2,
35 conversations_pb2,
36 events_pb2,
37 messages_pb2,
38 moderation_pb2,
39 notifications_pb2,
40 requests_pb2,
41)
42from couchers.utils import Timestamp_from_datetime, datetime_to_iso8601_local, now, today
43from tests.fixtures.db import backdate_conversations, generate_user, make_friends
44from tests.fixtures.misc import EmailCollector, PushCollector, process_jobs
45from tests.fixtures.sessions import (
46 api_session,
47 conversations_session,
48 events_session,
49 notifications_session,
50 real_moderation_session,
51 requests_session,
52)
53from tests.test_communities import create_community
54from tests.test_requests import valid_request_text
57@pytest.fixture(autouse=True)
58def _(testconfig):
59 pass
62def create_test_host_request_with_moderation(surfer_token, host_user_id):
63 """Helper to create a host request and return its moderation state ID"""
64 today_plus_2 = (today() + timedelta(days=2)).isoformat()
65 today_plus_3 = (today() + timedelta(days=3)).isoformat()
67 with requests_session(surfer_token) as api:
68 hr_id = api.CreateHostRequest(
69 requests_pb2.CreateHostRequestReq(
70 host_user_id=host_user_id,
71 from_date=today_plus_2,
72 to_date=today_plus_3,
73 text=valid_request_text(),
74 )
75 ).host_request_id
77 backdate_conversations()
79 with session_scope() as session:
80 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
81 return hr.moderation_state_id
84# ============================================================================
85# Tests for moderation helper functions
86# ============================================================================
89def test_create_moderation(db):
90 """Test creating a moderation state with associated log entry"""
91 user, _ = generate_user()
93 with session_scope() as session:
94 # Create a moderation state
95 moderation_state = create_moderation(
96 session=session,
97 object_type=ModerationObjectType.host_request,
98 object_id=123,
99 creator_user_id=user.id,
100 )
102 assert moderation_state.object_type == ModerationObjectType.host_request
103 assert moderation_state.object_id == 123
104 assert moderation_state.visibility == ModerationVisibility.shadowed
106 # Check that log entry was created
107 log_entries = (
108 session.execute(select(ModerationLog).where(ModerationLog.moderation_state_id == moderation_state.id))
109 .scalars()
110 .all()
111 )
113 assert len(log_entries) == 1
114 assert log_entries[0].action == ModerationAction.create
115 assert log_entries[0].reason == "Object created."
116 assert log_entries[0].moderator_user_id == user.id
119def test_add_to_moderation_queue(db):
120 """Test adding content to moderation queue via API"""
121 super_user, super_token = generate_user(is_superuser=True)
122 user1, token1 = generate_user()
123 user2, _ = generate_user()
125 today_plus_2 = (today() + timedelta(days=2)).isoformat()
126 today_plus_3 = (today() + timedelta(days=3)).isoformat()
128 # Create a real host request (which automatically creates moderation state and adds to queue)
129 with requests_session(token1) as api:
130 host_request_id = api.CreateHostRequest(
131 requests_pb2.CreateHostRequestReq(
132 host_user_id=user2.id,
133 from_date=today_plus_2,
134 to_date=today_plus_3,
135 text=valid_request_text(),
136 )
137 ).host_request_id
139 # Get the moderation state ID
140 state_id = None
141 with session_scope() as session:
142 host_request = session.execute(
143 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
144 ).scalar_one()
145 state_id = host_request.moderation_state_id
147 # Add another item to moderation queue via API (the first one was created automatically)
148 with real_moderation_session(super_token) as api:
149 api.ModerateContent(
150 moderation_pb2.ModerateContentReq(
151 moderation_state_id=state_id,
152 action=moderation_pb2.MODERATION_ACTION_FLAG,
153 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
154 reason="Admin manually flagged for additional review",
155 )
156 )
158 with session_scope() as session:
159 flag = session.execute(
160 select(ModerationQueueItem)
161 .where(ModerationQueueItem.moderation_state_id == state_id)
162 .where(ModerationQueueItem.trigger == ModerationTrigger.user_flag)
163 ).scalar_one()
164 assert flag.reason == "Admin manually flagged for additional review"
165 assert flag.resolved_by_log_id is None
167 # The FLAG action is recorded in the log, pointing at the new queue item.
168 flag_log = session.execute(
169 select(ModerationLog)
170 .where(ModerationLog.moderation_state_id == state_id)
171 .where(ModerationLog.action == ModerationAction.flag)
172 ).scalar_one()
173 assert flag_log.queue_item_id == flag.id
176def test_moderate_content(db):
177 """Test moderating content via API"""
178 super_user, super_token = generate_user(is_superuser=True)
179 user, token = generate_user()
180 host, _ = generate_user()
182 today_plus_2 = (today() + timedelta(days=2)).isoformat()
183 today_plus_3 = (today() + timedelta(days=3)).isoformat()
185 # Create a real host request
186 state_id = None
187 with requests_session(token) as api:
188 hr_id = api.CreateHostRequest(
189 requests_pb2.CreateHostRequestReq(
190 host_user_id=host.id,
191 from_date=today_plus_2,
192 to_date=today_plus_3,
193 text=valid_request_text(),
194 )
195 ).host_request_id
197 with session_scope() as session:
198 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
199 state_id = hr.moderation_state_id
201 # Moderate the content via API
202 with real_moderation_session(super_token) as api:
203 res = api.ModerateContent(
204 moderation_pb2.ModerateContentReq(
205 moderation_state_id=state_id,
206 action=moderation_pb2.MODERATION_ACTION_APPROVE,
207 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
208 reason="Content looks good",
209 )
210 )
212 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
214 # Check that state was updated in database
215 with session_scope() as session:
216 updated_state = session.get_one(ModerationState, state_id)
217 assert updated_state.visibility == ModerationVisibility.visible
219 # Check that log entry was created
220 log_entries = (
221 session.execute(
222 select(ModerationLog)
223 .where(ModerationLog.moderation_state_id == state_id)
224 .order_by(ModerationLog.time.desc(), ModerationLog.id.desc())
225 )
226 .scalars()
227 .all()
228 )
230 assert len(log_entries) == 2 # CREATE + APPROVE
231 assert log_entries[0].action == ModerationAction.approve
232 assert log_entries[0].moderator_user_id == super_user.id
233 assert log_entries[0].reason == "Content looks good"
236def test_resolve_queue_item(db):
237 """Test resolving a moderation queue item via ModerateContent API"""
238 user1, token1 = generate_user()
239 user2, _ = generate_user()
240 moderator, moderator_token = generate_user(is_superuser=True)
242 today_plus_2 = (today() + timedelta(days=2)).isoformat()
243 today_plus_3 = (today() + timedelta(days=3)).isoformat()
245 # Create a host request using the API (which automatically creates moderation state)
246 with requests_session(token1) as api:
247 host_request_id = api.CreateHostRequest(
248 requests_pb2.CreateHostRequestReq(
249 host_user_id=user2.id,
250 from_date=today_plus_2,
251 to_date=today_plus_3,
252 text=valid_request_text(),
253 )
254 ).host_request_id
256 state_id = None
257 with session_scope() as session:
258 # Get the host request and its moderation state
259 host_request = session.execute(
260 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
261 ).scalar_one()
262 state_id = host_request.moderation_state_id
264 # The moderation state should already exist and be in the queue
265 queue_item = session.execute(
266 select(ModerationQueueItem)
267 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
268 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
269 ).scalar_one()
271 assert queue_item.resolved_by_log_id is None
273 # Approve content with clear_flags, which resolves the open queue item(s)
274 with real_moderation_session(moderator_token) as api:
275 api.ModerateContent(
276 moderation_pb2.ModerateContentReq(
277 moderation_state_id=state_id,
278 action=moderation_pb2.MODERATION_ACTION_APPROVE,
279 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
280 reason="Approved after review",
281 clear_flags=True,
282 )
283 )
285 # Check that queue item was resolved
286 with session_scope() as session:
287 queue_item = session.execute(
288 select(ModerationQueueItem)
289 .where(ModerationQueueItem.moderation_state_id == state_id)
290 .where(ModerationQueueItem.resolved_by_log_id.is_not(None))
291 ).scalar_one()
292 assert queue_item.resolved_by_log_id is not None
295def test_approve_content_via_api(db):
296 """Test approving content via ModerateContent API"""
297 user1, token1 = generate_user()
298 user2, _ = generate_user()
299 moderator, moderator_token = generate_user(is_superuser=True)
301 today_plus_2 = (today() + timedelta(days=2)).isoformat()
302 today_plus_3 = (today() + timedelta(days=3)).isoformat()
304 # Create a host request using the API (which automatically creates moderation state)
305 with requests_session(token1) as api:
306 host_request_id = api.CreateHostRequest(
307 requests_pb2.CreateHostRequestReq(
308 host_user_id=user2.id,
309 from_date=today_plus_2,
310 to_date=today_plus_3,
311 text=valid_request_text(),
312 )
313 ).host_request_id
315 state_id = None
316 with session_scope() as session:
317 # Get the host request and its moderation state
318 host_request = session.execute(
319 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
320 ).scalar_one()
321 state_id = host_request.moderation_state_id
323 # Approve via API
324 with real_moderation_session(moderator_token) as api:
325 api.ModerateContent(
326 moderation_pb2.ModerateContentReq(
327 moderation_state_id=state_id,
328 action=moderation_pb2.MODERATION_ACTION_APPROVE,
329 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
330 reason="Quick approval",
331 )
332 )
334 # Check that state was updated to VISIBLE
335 with session_scope() as session:
336 updated_state = session.get_one(ModerationState, state_id)
337 assert updated_state.visibility == ModerationVisibility.visible
339 # Check log entry
340 log_entry = session.execute(
341 select(ModerationLog)
342 .where(ModerationLog.moderation_state_id == state_id)
343 .where(ModerationLog.action == ModerationAction.approve)
344 ).scalar_one()
346 assert log_entry.moderator_user_id == moderator.id
347 assert log_entry.reason == "Quick approval"
350# ============================================================================
351# Tests for host request moderation integration
352# ============================================================================
355def test_create_host_request_creates_moderation_state(db):
356 """Test that creating a host request automatically creates a moderation state"""
357 user1, token1 = generate_user()
358 user2, token2 = generate_user()
360 today_plus_2 = (today() + timedelta(days=2)).isoformat()
361 today_plus_3 = (today() + timedelta(days=3)).isoformat()
363 with requests_session(token1) as api:
364 host_request_id = api.CreateHostRequest(
365 requests_pb2.CreateHostRequestReq(
366 host_user_id=user2.id,
367 from_date=today_plus_2,
368 to_date=today_plus_3,
369 text=valid_request_text(),
370 )
371 ).host_request_id
373 with session_scope() as session:
374 # Check that host request has a moderation state
375 host_request = session.execute(
376 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
377 ).scalar_one()
379 # Check moderation state properties
380 moderation_state = session.execute(
381 select(ModerationState).where(ModerationState.id == host_request.moderation_state_id)
382 ).scalar_one()
384 assert moderation_state.object_type == ModerationObjectType.host_request
385 assert moderation_state.object_id == host_request_id
386 assert moderation_state.visibility == ModerationVisibility.shadowed
388 # Check that it was added to moderation queue
389 queue_items = (
390 session.execute(
391 select(ModerationQueueItem)
392 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
393 .where(ModerationQueueItem.resolved_by_log_id == None)
394 )
395 .scalars()
396 .all()
397 )
399 assert len(queue_items) == 1
400 assert queue_items[0].trigger == ModerationTrigger.initial_review
401 # item_author_user_id is no longer stored in the model, it's dynamically retrieved
404def test_host_request_no_notification_before_approval(db, push_collector: PushCollector):
405 """Test that host requests don't send notifications until approved"""
406 user1, token1 = generate_user()
407 user2, token2 = generate_user()
409 today_plus_2 = (today() + timedelta(days=2)).isoformat()
410 today_plus_3 = (today() + timedelta(days=3)).isoformat()
412 with requests_session(token1) as api:
413 host_request_id = api.CreateHostRequest(
414 requests_pb2.CreateHostRequestReq(
415 host_user_id=user2.id,
416 from_date=today_plus_2,
417 to_date=today_plus_3,
418 text=valid_request_text(),
419 )
420 ).host_request_id
422 # Process all jobs (including the notification job)
423 process_jobs()
425 # No push notification should be sent yet (host requests are shadowed initially)
426 assert push_collector.count_for_user(user2.id) == 0
429def test_shadowed_notification_not_in_list_notifications(db):
430 """Test that notifications for shadowed content don't appear in ListNotifications API"""
431 user1, token1 = generate_user()
432 user2, token2 = generate_user()
434 today_plus_2 = (today() + timedelta(days=2)).isoformat()
435 today_plus_3 = (today() + timedelta(days=3)).isoformat()
437 # Create a host request (which creates a shadowed notification for the host)
438 with requests_session(token1) as api:
439 host_request_id = api.CreateHostRequest(
440 requests_pb2.CreateHostRequestReq(
441 host_user_id=user2.id,
442 from_date=today_plus_2,
443 to_date=today_plus_3,
444 text=valid_request_text(),
445 )
446 ).host_request_id
448 # Host (recipient) should NOT see the notification in ListNotifications - it's for shadowed content
449 with notifications_session(token2) as api:
450 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
451 # Should be empty - the host request is still shadowed
452 assert len(res.notifications) == 0
455def test_notification_visible_after_approval(db):
456 """Test that notifications appear in ListNotifications after content is approved"""
457 user1, token1 = generate_user()
458 user2, token2 = generate_user()
459 mod, mod_token = generate_user(is_superuser=True)
461 today_plus_2 = (today() + timedelta(days=2)).isoformat()
462 today_plus_3 = (today() + timedelta(days=3)).isoformat()
464 # Create a host request (which creates a shadowed notification for the host)
465 with requests_session(token1) as api:
466 host_request_id = api.CreateHostRequest(
467 requests_pb2.CreateHostRequestReq(
468 host_user_id=user2.id,
469 from_date=today_plus_2,
470 to_date=today_plus_3,
471 text=valid_request_text(),
472 )
473 ).host_request_id
475 # Host (recipient) should NOT see the notification initially
476 with notifications_session(token2) as api:
477 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
478 assert len(res.notifications) == 0
480 # Get the moderation state ID and approve
481 with session_scope() as session:
482 host_request = session.execute(
483 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
484 ).scalar_one()
485 state_id = host_request.moderation_state_id
487 with real_moderation_session(mod_token) as api:
488 api.ModerateContent(
489 moderation_pb2.ModerateContentReq(
490 moderation_state_id=state_id,
491 action=moderation_pb2.MODERATION_ACTION_APPROVE,
492 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
493 reason="Looks good",
494 )
495 )
497 # Now host SHOULD see the notification
498 with notifications_session(token2) as api:
499 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
500 assert len(res.notifications) == 1
501 assert res.notifications[0].topic == "host_request"
502 assert res.notifications[0].action == "create"
505def test_shadowed_host_request_visible_to_author_only(db):
506 """Test that SHADOWED host requests are visible only to the author (surfer), not the recipient (host)"""
507 user1, token1 = generate_user()
508 user2, token2 = generate_user()
510 today_plus_2 = (today() + timedelta(days=2)).isoformat()
511 today_plus_3 = (today() + timedelta(days=3)).isoformat()
513 with requests_session(token1) as api:
514 host_request_id = api.CreateHostRequest(
515 requests_pb2.CreateHostRequestReq(
516 host_user_id=user2.id,
517 from_date=today_plus_2,
518 to_date=today_plus_3,
519 text=valid_request_text(),
520 )
521 ).host_request_id
523 # Surfer (author) can see it with GetHostRequest
524 with requests_session(token1) as api:
525 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
526 assert res.host_request_id == host_request_id
527 assert res.latest_message.text.text == valid_request_text()
529 # Host (recipient) CANNOT see it with GetHostRequest - it's shadowed
530 with requests_session(token2) as api:
531 with pytest.raises(grpc.RpcError) as e:
532 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
533 assert e.value.code() == grpc.StatusCode.NOT_FOUND
536def test_unlisted_host_request_not_in_lists(db):
537 """Test that SHADOWED host requests are visible to author but not to recipient"""
538 user1, token1 = generate_user()
539 user2, token2 = generate_user()
541 today_plus_2 = (today() + timedelta(days=2)).isoformat()
542 today_plus_3 = (today() + timedelta(days=3)).isoformat()
544 with requests_session(token1) as api:
545 host_request_id = api.CreateHostRequest(
546 requests_pb2.CreateHostRequestReq(
547 host_user_id=user2.id,
548 from_date=today_plus_2,
549 to_date=today_plus_3,
550 text=valid_request_text(),
551 )
552 ).host_request_id
554 # Surfer (author) should see it in their sent list even though it's SHADOWED
555 with requests_session(token1) as api:
556 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
557 assert len(res.host_requests) == 1
559 # Host should NOT see it in their received list (still SHADOWED from them)
560 with requests_session(token2) as api:
561 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
562 assert len(res.host_requests) == 0
565def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector):
566 """Test that approved host requests appear in lists and send notifications"""
567 user1, token1 = generate_user()
568 user2, token2 = generate_user()
569 mod, mod_token = generate_user(is_superuser=True)
571 today_plus_2 = (today() + timedelta(days=2)).isoformat()
572 today_plus_3 = (today() + timedelta(days=3)).isoformat()
574 with requests_session(token1) as api:
575 host_request_id = api.CreateHostRequest(
576 requests_pb2.CreateHostRequestReq(
577 host_user_id=user2.id,
578 from_date=today_plus_2,
579 to_date=today_plus_3,
580 text=valid_request_text(),
581 )
582 ).host_request_id
584 # Process the initial notification job - should be deferred (no notification sent)
585 process_jobs()
586 assert push_collector.count_for_user(user2.id) == 0
588 # Get the moderation state ID
589 state_id = None
590 with session_scope() as session:
591 host_request = session.execute(
592 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
593 ).scalar_one()
594 state_id = host_request.moderation_state_id
596 # Approve the host request via API
597 with real_moderation_session(mod_token) as api:
598 api.ModerateContent(
599 moderation_pb2.ModerateContentReq(
600 moderation_state_id=state_id,
601 action=moderation_pb2.MODERATION_ACTION_APPROVE,
602 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
603 reason="Looks good",
604 )
605 )
607 # Process the re-queued notification job - should now send notification
608 process_jobs()
610 # Now surfer SHOULD see it in their sent list
611 with requests_session(token1) as api:
612 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
613 assert len(res.host_requests) == 1
614 assert res.host_requests[0].host_request_id == host_request_id
616 # Host SHOULD see it in their received list
617 with requests_session(token2) as api:
618 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
619 assert len(res.host_requests) == 1
620 assert res.host_requests[0].host_request_id == host_request_id
622 # After approval, the host should have received a push notification
623 assert push_collector.pop_for_user(user2.id, last=True).topic_action == "host_request:create"
626def test_hidden_host_request_invisible_to_all(db):
627 """Test that HIDDEN host requests are invisible to everyone except moderators"""
628 user1, token1 = generate_user()
629 user2, token2 = generate_user()
630 user3, token3 = generate_user() # Third party
631 moderator, moderator_token = generate_user(is_superuser=True)
633 today_plus_2 = (today() + timedelta(days=2)).isoformat()
634 today_plus_3 = (today() + timedelta(days=3)).isoformat()
636 with requests_session(token1) as api:
637 host_request_id = api.CreateHostRequest(
638 requests_pb2.CreateHostRequestReq(
639 host_user_id=user2.id,
640 from_date=today_plus_2,
641 to_date=today_plus_3,
642 text=valid_request_text(),
643 )
644 ).host_request_id
646 # Get the moderation state ID
647 state_id = None
648 with session_scope() as session:
649 host_request = session.execute(
650 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
651 ).scalar_one()
652 state_id = host_request.moderation_state_id
654 # Hide the host request via API (e.g., spam/abuse)
655 with real_moderation_session(moderator_token) as api:
656 api.ModerateContent(
657 moderation_pb2.ModerateContentReq(
658 moderation_state_id=state_id,
659 action=moderation_pb2.MODERATION_ACTION_HIDE,
660 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
661 reason="Spam content",
662 )
663 )
665 # Surfer can't see it with GetHostRequest
666 with requests_session(token1) as api:
667 with pytest.raises(grpc.RpcError) as e:
668 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
669 assert e.value.code() == grpc.StatusCode.NOT_FOUND
671 # Host can't see it with GetHostRequest
672 with requests_session(token2) as api:
673 with pytest.raises(grpc.RpcError) as e:
674 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
675 assert e.value.code() == grpc.StatusCode.NOT_FOUND
677 # Third party definitely can't see it
678 with requests_session(token3) as api:
679 with pytest.raises(grpc.RpcError) as e:
680 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
681 assert e.value.code() == grpc.StatusCode.NOT_FOUND
683 # Not in any lists
684 with requests_session(token1) as api:
685 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
686 assert len(res.host_requests) == 0
688 with requests_session(token2) as api:
689 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
690 assert len(res.host_requests) == 0
693def test_multiple_host_requests_listing_visibility(db):
694 """Test that ListHostRequests correctly filters based on moderation state"""
695 user1, token1 = generate_user()
696 user2, token2 = generate_user()
697 moderator, moderator_token = generate_user(is_superuser=True)
699 today_plus_2 = (today() + timedelta(days=2)).isoformat()
700 today_plus_3 = (today() + timedelta(days=3)).isoformat()
702 # Create 3 host requests
703 host_request_ids = []
704 state_ids = []
705 with requests_session(token1) as api:
706 for i in range(3):
707 hr_id = api.CreateHostRequest(
708 requests_pb2.CreateHostRequestReq(
709 host_user_id=user2.id,
710 from_date=today_plus_2,
711 to_date=today_plus_3,
712 text=valid_request_text(f"Test request {i + 1}"),
713 )
714 ).host_request_id
715 host_request_ids.append(hr_id)
716 backdate_conversations()
718 # Get state IDs
719 with session_scope() as session:
720 for hr_id in host_request_ids:
721 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
722 state_ids.append(host_request.moderation_state_id)
724 # Approve the first one via API
725 with real_moderation_session(moderator_token) as api:
726 api.ModerateContent(
727 moderation_pb2.ModerateContentReq(
728 moderation_state_id=state_ids[0],
729 action=moderation_pb2.MODERATION_ACTION_APPROVE,
730 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
731 reason="Approved",
732 )
733 )
735 # Hide the third one via API
736 with real_moderation_session(moderator_token) as api:
737 api.ModerateContent(
738 moderation_pb2.ModerateContentReq(
739 moderation_state_id=state_ids[2],
740 action=moderation_pb2.MODERATION_ACTION_HIDE,
741 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
742 reason="Spam",
743 )
744 )
746 # Surfer should see the approved one and the shadowed one (author can see their SHADOWED content)
747 with requests_session(token1) as api:
748 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
749 assert len(res.host_requests) == 2
750 visible_ids = {hr.host_request_id for hr in res.host_requests}
751 assert visible_ids == {host_request_ids[0], host_request_ids[1]}
753 # Host should see only the approved one in received list
754 with requests_session(token2) as api:
755 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
756 assert len(res.host_requests) == 1
757 assert res.host_requests[0].host_request_id == host_request_ids[0]
760def test_moderation_log_tracking(db):
761 """Test that moderation actions are properly logged via API"""
762 user, user_token = generate_user()
763 host, _ = generate_user()
764 moderator1, moderator1_token = generate_user(is_superuser=True)
765 moderator2, moderator2_token = generate_user(is_superuser=True)
767 # Create a real host request
768 state_id = create_test_host_request_with_moderation(user_token, host.id)
770 # Perform several moderation actions via API
771 with real_moderation_session(moderator1_token) as api:
772 api.ModerateContent(
773 moderation_pb2.ModerateContentReq(
774 moderation_state_id=state_id,
775 action=moderation_pb2.MODERATION_ACTION_APPROVE,
776 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
777 reason="Looks good initially",
778 )
779 )
781 with real_moderation_session(moderator2_token) as api:
782 api.ModerateContent(
783 moderation_pb2.ModerateContentReq(
784 moderation_state_id=state_id,
785 action=moderation_pb2.MODERATION_ACTION_FLAG,
786 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
787 reason="Wait, this needs another look",
788 )
789 )
790 # Shadow it back
791 api.ModerateContent(
792 moderation_pb2.ModerateContentReq(
793 moderation_state_id=state_id,
794 action=moderation_pb2.MODERATION_ACTION_HIDE,
795 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
796 reason="Wait, this needs another look",
797 )
798 )
800 with real_moderation_session(moderator1_token) as api:
801 api.ModerateContent(
802 moderation_pb2.ModerateContentReq(
803 moderation_state_id=state_id,
804 action=moderation_pb2.MODERATION_ACTION_HIDE,
805 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
806 reason="Actually it's spam",
807 )
808 )
810 # Check all log entries
811 with session_scope() as session:
812 log_entries = (
813 session.execute(
814 select(ModerationLog)
815 .where(ModerationLog.moderation_state_id == state_id)
816 .order_by(ModerationLog.time.asc())
817 )
818 .scalars()
819 .all()
820 )
822 # CREATE + APPROVE + HIDE + HIDE (shadowing back counts as HIDE action)
823 assert len(log_entries) >= 3
825 assert log_entries[0].action == ModerationAction.create
826 assert log_entries[0].moderator_user_id == user.id
827 assert log_entries[0].reason == "Object created."
829 assert log_entries[1].action == ModerationAction.approve
830 assert log_entries[1].moderator_user_id == moderator1.id
831 assert log_entries[1].reason == "Looks good initially"
833 # The last action should be hiding
834 assert log_entries[-1].action == ModerationAction.hide
835 assert log_entries[-1].moderator_user_id == moderator1.id
836 assert log_entries[-1].reason == "Actually it's spam"
839def test_moderation_queue_workflow(db):
840 """Test the full moderation queue workflow via API"""
841 user1, token1 = generate_user()
842 user2, _ = generate_user()
843 moderator, moderator_token = generate_user(is_superuser=True)
845 today_plus_2 = (today() + timedelta(days=2)).isoformat()
846 today_plus_3 = (today() + timedelta(days=3)).isoformat()
848 # Create a host request using the API (which automatically creates moderation state and adds to queue)
849 with requests_session(token1) as api:
850 host_request_id = api.CreateHostRequest(
851 requests_pb2.CreateHostRequestReq(
852 host_user_id=user2.id,
853 from_date=today_plus_2,
854 to_date=today_plus_3,
855 text=valid_request_text(),
856 )
857 ).host_request_id
859 state_id = None
860 queue_item_id = None
861 with session_scope() as session:
862 # Get the host request and its moderation state
863 host_request = session.execute(
864 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
865 ).scalar_one()
866 state_id = host_request.moderation_state_id
868 # The queue item should already exist (created automatically)
869 queue_item = session.execute(
870 select(ModerationQueueItem)
871 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
872 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
873 ).scalar_one()
874 queue_item_id = queue_item.id
876 # Verify it's in the queue
877 unresolved_items = (
878 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
879 .scalars()
880 .all()
881 )
883 assert len(unresolved_items) >= 1
884 assert queue_item.id in [item.id for item in unresolved_items]
886 # Moderator reviews and approves via API, clearing the open queue item
887 with real_moderation_session(moderator_token) as api:
888 api.ModerateContent(
889 moderation_pb2.ModerateContentReq(
890 moderation_state_id=state_id,
891 action=moderation_pb2.MODERATION_ACTION_APPROVE,
892 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
893 reason="Content approved",
894 clear_flags=True,
895 )
896 )
898 # Verify queue item was resolved
899 with session_scope() as session:
900 # Verify it's no longer in unresolved queue
901 unresolved_items = (
902 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
903 .scalars()
904 .all()
905 )
907 assert queue_item_id not in [item.id for item in unresolved_items]
909 # Verify the queue item was linked to a log entry
910 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
911 assert queue_item.resolved_by_log_id is not None
914# ============================================================================
915# Moderation API Tests (testing the gRPC servicer)
916# ============================================================================
919def test_GetModerationQueue_empty(db):
920 """Test getting an empty moderation queue"""
921 super_user, super_token = generate_user(is_superuser=True)
923 with real_moderation_session(super_token) as api:
924 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
925 assert len(res.queue_items) == 0
926 assert res.next_page_token == ""
929def test_GetModerationQueue_with_items(db):
930 """Test getting moderation queue with items via API"""
931 super_user, super_token = generate_user(is_superuser=True)
932 normal_user, user_token = generate_user()
933 host, _ = generate_user()
935 # Create some host requests (which automatically adds them to moderation queue)
936 state1_id = create_test_host_request_with_moderation(user_token, host.id)
937 state2_id = create_test_host_request_with_moderation(user_token, host.id)
939 with real_moderation_session(super_token) as api:
940 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
941 assert len(res.queue_items) == 2
942 assert res.queue_items[0].is_resolved == False
943 assert res.queue_items[1].is_resolved == False
946def test_GetModerationQueue_filter_by_trigger(db):
947 """Test filtering moderation queue by trigger type via API"""
948 super_user, super_token = generate_user(is_superuser=True)
949 normal_user, user_token = generate_user()
950 host, _ = generate_user()
952 # Create host requests (which automatically adds them to moderation queue with INITIAL_REVIEW)
953 state1_id = create_test_host_request_with_moderation(user_token, host.id)
954 state2_id = create_test_host_request_with_moderation(user_token, host.id)
956 # Add USER_FLAG trigger to second item via API
957 with real_moderation_session(super_token) as api:
958 api.ModerateContent(
959 moderation_pb2.ModerateContentReq(
960 moderation_state_id=state2_id,
961 action=moderation_pb2.MODERATION_ACTION_FLAG,
962 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
963 reason="Reported by user",
964 )
965 )
967 # Filter by INITIAL_REVIEW (should get first item and maybe both depending on how queue works)
968 with real_moderation_session(super_token) as api:
969 res = api.GetModerationQueue(
970 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW])
971 )
972 assert len(res.queue_items) == 2 # Both have INITIAL_REVIEW triggers
973 assert all(item.trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW for item in res.queue_items)
975 # Filter by USER_FLAG (should get second item only)
976 with real_moderation_session(super_token) as api:
977 res = api.GetModerationQueue(
978 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_USER_FLAG])
979 )
980 assert len(res.queue_items) == 1
981 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG
984def test_GetModerationQueue_filter_created_before(db):
985 """Test filtering moderation queue by created_before timestamp"""
986 super_user, super_token = generate_user(is_superuser=True)
987 normal_user, user_token = generate_user()
988 host, _ = generate_user()
990 # Create host requests
991 state1_id = create_test_host_request_with_moderation(user_token, host.id)
992 state2_id = create_test_host_request_with_moderation(user_token, host.id)
994 # Backdate the first queue item
995 with session_scope() as session:
996 queue_item1 = session.execute(
997 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
998 ).scalar_one()
999 # Set it to 2 hours ago
1000 queue_item1.time_created = now() - timedelta(hours=2)
1002 # The second item remains at current time
1004 # Filter to items created before 1 hour ago (should only get the first item)
1005 cutoff_time = now() - timedelta(hours=1)
1006 with real_moderation_session(super_token) as api:
1007 res = api.GetModerationQueue(
1008 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(cutoff_time))
1009 )
1010 assert len(res.queue_items) == 1
1011 assert res.queue_items[0].moderation_state_id == state1_id
1013 # Filter to items created before now (should get both)
1014 with real_moderation_session(super_token) as api:
1015 res = api.GetModerationQueue(
1016 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(now() + timedelta(seconds=10)))
1017 )
1018 assert len(res.queue_items) == 2
1020 # Filter to items created before 3 hours ago (should get none)
1021 old_cutoff = now() - timedelta(hours=3)
1022 with real_moderation_session(super_token) as api:
1023 res = api.GetModerationQueue(
1024 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(old_cutoff))
1025 )
1026 assert len(res.queue_items) == 0
1029def test_GetModerationQueue_filter_created_after(db):
1030 """Test filtering moderation queue by created_after timestamp"""
1031 super_user, super_token = generate_user(is_superuser=True)
1032 normal_user, user_token = generate_user()
1033 host, _ = generate_user()
1035 # Create host requests
1036 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1037 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1039 # Backdate the first queue item to 2 hours ago
1040 with session_scope() as session:
1041 queue_item1 = session.execute(
1042 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1043 ).scalar_one()
1044 queue_item1.time_created = now() - timedelta(hours=2)
1046 # The second item remains at current time
1048 # Filter to items created after 1 hour ago (should only get the second item)
1049 cutoff_time = now() - timedelta(hours=1)
1050 with real_moderation_session(super_token) as api:
1051 res = api.GetModerationQueue(
1052 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(cutoff_time))
1053 )
1054 assert len(res.queue_items) == 1
1055 assert res.queue_items[0].moderation_state_id == state2_id
1057 # Filter to items created after 3 hours ago (should get both)
1058 old_cutoff = now() - timedelta(hours=3)
1059 with real_moderation_session(super_token) as api:
1060 res = api.GetModerationQueue(
1061 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(old_cutoff))
1062 )
1063 assert len(res.queue_items) == 2
1065 # Filter to items created after now (should get none)
1066 with real_moderation_session(super_token) as api:
1067 res = api.GetModerationQueue(
1068 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(now() + timedelta(seconds=10)))
1069 )
1070 assert len(res.queue_items) == 0
1073def test_GetModerationQueue_filter_created_before_and_after(db):
1074 """Test filtering moderation queue by both created_before and created_after timestamps"""
1075 super_user, super_token = generate_user(is_superuser=True)
1076 normal_user, user_token = generate_user()
1077 host, _ = generate_user()
1079 # Create 3 host requests
1080 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1081 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1082 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1084 # Set different times: state1 = 3 hours ago, state2 = 1.5 hours ago, state3 = now
1085 with session_scope() as session:
1086 queue_item1 = session.execute(
1087 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1088 ).scalar_one()
1089 queue_item1.time_created = now() - timedelta(hours=3)
1091 queue_item2 = session.execute(
1092 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1093 ).scalar_one()
1094 queue_item2.time_created = now() - timedelta(hours=1, minutes=30)
1096 # Filter to items between 2 hours ago and 1 hour ago (should only get state2)
1097 after_cutoff = now() - timedelta(hours=2)
1098 before_cutoff = now() - timedelta(hours=1)
1099 with real_moderation_session(super_token) as api:
1100 res = api.GetModerationQueue(
1101 moderation_pb2.GetModerationQueueReq(
1102 created_after=Timestamp_from_datetime(after_cutoff),
1103 created_before=Timestamp_from_datetime(before_cutoff),
1104 )
1105 )
1106 assert len(res.queue_items) == 1
1107 assert res.queue_items[0].moderation_state_id == state2_id
1109 # Filter to items between 4 hours ago and 2.5 hours ago (should only get state1)
1110 after_cutoff = now() - timedelta(hours=4)
1111 before_cutoff = now() - timedelta(hours=2, minutes=30)
1112 with real_moderation_session(super_token) as api:
1113 res = api.GetModerationQueue(
1114 moderation_pb2.GetModerationQueueReq(
1115 created_after=Timestamp_from_datetime(after_cutoff),
1116 created_before=Timestamp_from_datetime(before_cutoff),
1117 )
1118 )
1119 assert len(res.queue_items) == 1
1120 assert res.queue_items[0].moderation_state_id == state1_id
1123def test_GetModerationQueue_filter_unresolved(db):
1124 """Test filtering moderation queue for unresolved items only via API"""
1125 super_user, super_token = generate_user(is_superuser=True)
1126 normal_user, user_token = generate_user()
1127 host, _ = generate_user()
1129 # Create 2 host requests
1130 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1131 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1133 # Resolve the first one via API (approve with clear_flags resolves the queue item)
1134 with real_moderation_session(super_token) as api:
1135 api.ModerateContent(
1136 moderation_pb2.ModerateContentReq(
1137 moderation_state_id=state1_id,
1138 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1139 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1140 reason="Approved",
1141 clear_flags=True,
1142 )
1143 )
1145 # Get all items
1146 with real_moderation_session(super_token) as api:
1147 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1148 assert len(res.queue_items) == 2
1150 # Get only unresolved items
1151 with real_moderation_session(super_token) as api:
1152 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1153 assert len(res.queue_items) == 1
1154 assert res.queue_items[0].is_resolved == False
1157def test_GetModerationQueue_filter_by_author(db):
1158 """Test filtering moderation queue by item_author_user_id"""
1159 super_user, super_token = generate_user(is_superuser=True)
1160 user1, token1 = generate_user()
1161 user2, token2 = generate_user()
1162 host_user, _ = generate_user()
1164 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1165 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1167 # Create 2 host requests by user1
1168 with requests_session(token1) as api:
1169 hr1_id = api.CreateHostRequest(
1170 requests_pb2.CreateHostRequestReq(
1171 host_user_id=host_user.id,
1172 from_date=today_plus_2,
1173 to_date=today_plus_3,
1174 text=valid_request_text(),
1175 )
1176 ).host_request_id
1177 backdate_conversations()
1179 hr2_id = api.CreateHostRequest(
1180 requests_pb2.CreateHostRequestReq(
1181 host_user_id=host_user.id,
1182 from_date=today_plus_2,
1183 to_date=today_plus_3,
1184 text=valid_request_text(),
1185 )
1186 ).host_request_id
1188 # Create 1 host request by user2
1189 with requests_session(token2) as api:
1190 hr3_id = api.CreateHostRequest(
1191 requests_pb2.CreateHostRequestReq(
1192 host_user_id=host_user.id,
1193 from_date=today_plus_2,
1194 to_date=today_plus_3,
1195 text=valid_request_text(),
1196 )
1197 ).host_request_id
1199 # Get moderation state IDs
1200 state1_id, state2_id, state3_id = None, None, None
1201 with session_scope() as session:
1202 hr1 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr1_id)).scalar_one()
1203 hr2 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr2_id)).scalar_one()
1204 hr3 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr3_id)).scalar_one()
1205 state1_id = hr1.moderation_state_id
1206 state2_id = hr2.moderation_state_id
1207 state3_id = hr3.moderation_state_id
1209 # Get all items (should be 3)
1210 with real_moderation_session(super_token) as api:
1211 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1212 assert len(res.queue_items) == 3
1214 # Filter by user1 (should get 2)
1215 with real_moderation_session(super_token) as api:
1216 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user1.id))
1217 assert len(res.queue_items) == 2
1218 assert all(item.moderation_state.author.user_id == user1.id for item in res.queue_items)
1220 # Filter by user2 (should get 1)
1221 with real_moderation_session(super_token) as api:
1222 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user2.id))
1223 assert len(res.queue_items) == 1
1224 assert res.queue_items[0].moderation_state.author.user_id == user2.id
1225 assert res.queue_items[0].moderation_state_id == state3_id
1227 # Filter by non-existent user (should get 0)
1228 with real_moderation_session(super_token) as api:
1229 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=999999))
1230 assert len(res.queue_items) == 0
1233def test_GetModerationQueue_ordering(db):
1234 """Test ordering moderation queue by oldest/newest first"""
1235 super_user, super_token = generate_user(is_superuser=True)
1236 normal_user, user_token = generate_user()
1237 host, _ = generate_user()
1239 # Create 3 host requests
1240 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1241 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1242 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1244 # Set different times: state1 = 3 hours ago, state2 = 2 hours ago, state3 = 1 hour ago
1245 with session_scope() as session:
1246 queue_item1 = session.execute(
1247 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1248 ).scalar_one()
1249 queue_item1.time_created = now() - timedelta(hours=3)
1251 queue_item2 = session.execute(
1252 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1253 ).scalar_one()
1254 queue_item2.time_created = now() - timedelta(hours=2)
1256 queue_item3 = session.execute(
1257 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state3_id)
1258 ).scalar_one()
1259 queue_item3.time_created = now() - timedelta(hours=1)
1261 # Default order (oldest first)
1262 with real_moderation_session(super_token) as api:
1263 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1264 assert len(res.queue_items) == 3
1265 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1266 assert res.queue_items[1].moderation_state_id == state2_id
1267 assert res.queue_items[2].moderation_state_id == state3_id # newest
1269 # Explicit oldest first
1270 with real_moderation_session(super_token) as api:
1271 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=False))
1272 assert len(res.queue_items) == 3
1273 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1274 assert res.queue_items[2].moderation_state_id == state3_id # newest
1276 # Newest first
1277 with real_moderation_session(super_token) as api:
1278 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=True))
1279 assert len(res.queue_items) == 3
1280 assert res.queue_items[0].moderation_state_id == state3_id # newest
1281 assert res.queue_items[1].moderation_state_id == state2_id
1282 assert res.queue_items[2].moderation_state_id == state1_id # oldest
1285def test_GetModerationQueue_pagination_newest_first(db):
1286 """Test pagination with newest_first=True returns different items on each page"""
1287 super_user, super_token = generate_user(is_superuser=True)
1288 normal_user, normal_token = generate_user()
1289 host_user, _ = generate_user()
1291 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1292 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1294 # Create 5 host requests
1295 hr_ids = []
1296 with requests_session(normal_token) as api:
1297 for i in range(5):
1298 hr_id = api.CreateHostRequest(
1299 requests_pb2.CreateHostRequestReq(
1300 host_user_id=host_user.id,
1301 from_date=today_plus_2,
1302 to_date=today_plus_3,
1303 text=valid_request_text(),
1304 )
1305 ).host_request_id
1306 hr_ids.append(hr_id)
1307 backdate_conversations()
1309 # Get moderation state IDs
1310 state_ids = []
1311 with session_scope() as session:
1312 for hr_id in hr_ids:
1313 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
1314 state_ids.append(hr.moderation_state_id)
1316 # Set different times so ordering is deterministic
1317 with session_scope() as session:
1318 for i, state_id in enumerate(state_ids):
1319 queue_item = session.execute(
1320 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
1321 ).scalar_one()
1322 queue_item.time_created = now() - timedelta(hours=5 - i) # oldest first in list
1324 # Get first page (2 items) with newest_first=True, filtered to our user's items
1325 with real_moderation_session(super_token) as api:
1326 res1 = api.GetModerationQueue(
1327 moderation_pb2.GetModerationQueueReq(page_size=2, newest_first=True, item_author_user_id=normal_user.id)
1328 )
1329 assert len(res1.queue_items) == 2
1330 # Should get newest items: state_ids[4], state_ids[3]
1331 assert res1.queue_items[0].moderation_state_id == state_ids[4]
1332 assert res1.queue_items[1].moderation_state_id == state_ids[3]
1333 assert res1.next_page_token # should have more pages
1335 # Get second page using the token
1336 res2 = api.GetModerationQueue(
1337 moderation_pb2.GetModerationQueueReq(
1338 page_size=2, newest_first=True, page_token=res1.next_page_token, item_author_user_id=normal_user.id
1339 )
1340 )
1341 assert len(res2.queue_items) == 2
1342 # Should get next newest items: state_ids[2], state_ids[1]
1343 assert res2.queue_items[0].moderation_state_id == state_ids[2]
1344 assert res2.queue_items[1].moderation_state_id == state_ids[1]
1346 # Pages should not overlap
1347 page1_ids = {item.moderation_state_id for item in res1.queue_items}
1348 page2_ids = {item.moderation_state_id for item in res2.queue_items}
1349 assert page1_ids.isdisjoint(page2_ids), "Pages should not have overlapping items"
1352def test_GetModerationLog(db):
1353 """Test getting moderation log for a state via API"""
1354 super_user, super_token = generate_user(is_superuser=True)
1355 moderator, moderator_token = generate_user(is_superuser=True)
1356 normal_user, user_token = generate_user()
1357 host, _ = generate_user()
1359 # Create a real host request
1360 state_id = create_test_host_request_with_moderation(user_token, host.id)
1362 # Perform a moderation action via API
1363 with real_moderation_session(moderator_token) as api:
1364 api.ModerateContent(
1365 moderation_pb2.ModerateContentReq(
1366 moderation_state_id=state_id,
1367 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1368 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1369 reason="Looks good",
1370 )
1371 )
1373 with real_moderation_session(super_token) as api:
1374 res = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
1375 assert len(res.log_entries) == 2 # CREATE + APPROVE
1376 assert res.moderation_state.moderation_state_id == state_id
1377 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1378 # Log entries are in reverse chronological order
1379 assert res.log_entries[0].action == moderation_pb2.MODERATION_ACTION_APPROVE
1380 assert res.log_entries[0].moderator_user_id == moderator.id
1381 assert res.log_entries[0].reason == "Looks good"
1382 assert res.log_entries[1].action == moderation_pb2.MODERATION_ACTION_CREATE
1383 assert res.log_entries[1].moderator_user_id == normal_user.id
1386def test_GetModerationLog_not_found(db):
1387 """Test getting moderation log for non-existent state"""
1388 super_user, super_token = generate_user(is_superuser=True)
1390 with real_moderation_session(super_token) as api:
1391 with pytest.raises(grpc.RpcError) as e:
1392 api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=999999))
1393 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1394 assert e.value.details() == "Moderation state not found."
1397def test_GetModerationState(db):
1398 """Test getting moderation state by object type and ID"""
1399 super_user, super_token = generate_user(is_superuser=True)
1400 user1, token1 = generate_user()
1401 user2, _ = generate_user()
1403 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1404 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1406 with requests_session(token1) as api:
1407 host_request_id = api.CreateHostRequest(
1408 requests_pb2.CreateHostRequestReq(
1409 host_user_id=user2.id,
1410 from_date=today_plus_2,
1411 to_date=today_plus_3,
1412 text=valid_request_text(),
1413 )
1414 ).host_request_id
1416 with real_moderation_session(super_token) as api:
1417 res = api.GetModerationState(
1418 moderation_pb2.GetModerationStateReq(
1419 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1420 object_id=host_request_id,
1421 )
1422 )
1423 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST
1424 assert res.moderation_state.object_id == host_request_id
1425 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1426 assert res.moderation_state.moderation_state_id > 0
1429def test_GetModerationState_not_found(db):
1430 """Test getting moderation state for non-existent object"""
1431 super_user, super_token = generate_user(is_superuser=True)
1433 with real_moderation_session(super_token) as api:
1434 with pytest.raises(grpc.RpcError) as e:
1435 api.GetModerationState(
1436 moderation_pb2.GetModerationStateReq(
1437 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1438 object_id=999999,
1439 )
1440 )
1441 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1442 assert e.value.details() == "Moderation state not found."
1445def test_GetModerationState_unspecified_type(db):
1446 """Test getting moderation state with unspecified object type"""
1447 super_user, super_token = generate_user(is_superuser=True)
1449 with real_moderation_session(super_token) as api:
1450 with pytest.raises(grpc.RpcError) as e:
1451 api.GetModerationState(
1452 moderation_pb2.GetModerationStateReq(
1453 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED,
1454 object_id=123,
1455 )
1456 )
1457 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1458 assert e.value.details() == "Object type must be specified."
1461def test_ModerateContent_approve(db):
1462 """Test approving content via unified moderation API"""
1463 super_user, super_token = generate_user(is_superuser=True)
1464 user1, token1 = generate_user()
1465 user2, _ = generate_user()
1467 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1468 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1470 # Create a host request using the API (which automatically creates moderation state)
1471 with requests_session(token1) as api:
1472 host_request_id = api.CreateHostRequest(
1473 requests_pb2.CreateHostRequestReq(
1474 host_user_id=user2.id,
1475 from_date=today_plus_2,
1476 to_date=today_plus_3,
1477 text=valid_request_text(),
1478 )
1479 ).host_request_id
1481 # Get the moderation state ID
1482 state_id = None
1483 with session_scope() as session:
1484 host_request = session.execute(
1485 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1486 ).scalar_one()
1487 state_id = host_request.moderation_state_id
1489 with real_moderation_session(super_token) as api:
1490 res = api.ModerateContent(
1491 moderation_pb2.ModerateContentReq(
1492 moderation_state_id=state_id,
1493 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1494 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1495 reason="Approved by admin",
1496 )
1497 )
1498 assert res.moderation_state.moderation_state_id == state_id
1499 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1501 # Verify state was updated in database
1502 with session_scope() as session:
1503 state = session.get_one(ModerationState, state_id)
1504 assert state.visibility == ModerationVisibility.visible
1507def test_ModerateContent_not_found(db):
1508 """Test moderating non-existent content"""
1509 super_user, super_token = generate_user(is_superuser=True)
1511 with real_moderation_session(super_token) as api:
1512 with pytest.raises(grpc.RpcError) as e:
1513 api.ModerateContent(
1514 moderation_pb2.ModerateContentReq(
1515 moderation_state_id=999999,
1516 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1517 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1518 reason="Test",
1519 )
1520 )
1521 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1522 assert e.value.details() == "Moderation state not found."
1525def test_ModerateContent_hide(db):
1526 """Test hiding content via unified moderation API"""
1527 super_user, super_token = generate_user(is_superuser=True)
1528 normal_user, user_token = generate_user()
1529 host, _ = generate_user()
1531 # Create a real host request
1532 state_id = create_test_host_request_with_moderation(user_token, host.id)
1534 with real_moderation_session(super_token) as api:
1535 res = api.ModerateContent(
1536 moderation_pb2.ModerateContentReq(
1537 moderation_state_id=state_id,
1538 action=moderation_pb2.MODERATION_ACTION_HIDE,
1539 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1540 reason="Spam content",
1541 )
1542 )
1543 assert res.moderation_state.moderation_state_id == state_id
1544 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_HIDDEN
1546 # Verify state was updated in database
1547 with session_scope() as session:
1548 state = session.get_one(ModerationState, state_id)
1549 assert state.visibility == ModerationVisibility.hidden
1552def test_ModerateContent_shadow(db):
1553 """Test shadowing content via unified moderation API"""
1554 super_user, super_token = generate_user(is_superuser=True)
1555 normal_user, user_token = generate_user()
1556 host, _ = generate_user()
1558 # Create a real host request
1559 state_id = create_test_host_request_with_moderation(user_token, host.id)
1561 with real_moderation_session(super_token) as api:
1562 res = api.ModerateContent(
1563 moderation_pb2.ModerateContentReq(
1564 moderation_state_id=state_id,
1565 action=moderation_pb2.MODERATION_ACTION_HIDE,
1566 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
1567 reason="Needs further review",
1568 )
1569 )
1570 assert res.moderation_state.moderation_state_id == state_id
1571 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1573 # Verify state was updated in database
1574 with session_scope() as session:
1575 state = session.get_one(ModerationState, state_id)
1576 assert state.visibility == ModerationVisibility.shadowed
1579def test_ModerateContent_flag(db):
1580 """Test opening a flag via the FLAG action"""
1581 super_user, super_token = generate_user(is_superuser=True)
1582 user1, token1 = generate_user()
1583 user2, _ = generate_user()
1585 # Create a host request
1586 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1587 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1589 with requests_session(token1) as api:
1590 host_request_id = api.CreateHostRequest(
1591 requests_pb2.CreateHostRequestReq(
1592 host_user_id=user2.id,
1593 from_date=today_plus_2,
1594 to_date=today_plus_3,
1595 text=valid_request_text(),
1596 )
1597 ).host_request_id
1599 # Get the moderation state ID
1600 with session_scope() as session:
1601 host_request = session.execute(
1602 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1603 ).scalar_one()
1604 state_id = host_request.moderation_state_id
1606 with real_moderation_session(super_token) as api:
1607 api.ModerateContent(
1608 moderation_pb2.ModerateContentReq(
1609 moderation_state_id=state_id,
1610 action=moderation_pb2.MODERATION_ACTION_FLAG,
1611 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
1612 reason="Admin flagged for additional review",
1613 priority=5,
1614 )
1615 )
1617 # Verify queue item was created in database with the given priority, plus a log row referencing it
1618 with session_scope() as session:
1619 queue_item = session.execute(
1620 select(ModerationQueueItem)
1621 .where(ModerationQueueItem.moderation_state_id == state_id)
1622 .where(ModerationQueueItem.trigger == ModerationTrigger.moderator_review)
1623 ).scalar_one()
1624 assert queue_item.resolved_by_log_id is None
1625 assert queue_item.priority == 5
1627 flag_log = session.execute(
1628 select(ModerationLog)
1629 .where(ModerationLog.moderation_state_id == state_id)
1630 .where(ModerationLog.action == ModerationAction.flag)
1631 ).scalar_one()
1632 assert flag_log.queue_item_id == queue_item.id
1635def test_ModerateContent_flag_requires_trigger(db):
1636 """FLAG without a trigger is rejected."""
1637 super_user, super_token = generate_user(is_superuser=True)
1638 user1, token1 = generate_user()
1639 user2, _ = generate_user()
1640 state_id = create_test_host_request_with_moderation(token1, user2.id)
1642 with real_moderation_session(super_token) as api:
1643 with pytest.raises(grpc.RpcError) as e:
1644 api.ModerateContent(
1645 moderation_pb2.ModerateContentReq(
1646 moderation_state_id=state_id,
1647 action=moderation_pb2.MODERATION_ACTION_FLAG,
1648 reason="no trigger",
1649 )
1650 )
1651 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1654def _open_queue_item_id(state_id):
1655 with session_scope() as session:
1656 return (
1657 session.execute(
1658 select(ModerationQueueItem)
1659 .where(ModerationQueueItem.moderation_state_id == state_id)
1660 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1661 )
1662 .scalars()
1663 .one()
1664 .id
1665 )
1668def test_ModerateContent_set_priority(db):
1669 """SET_PRIORITY changes a flag's priority and logs it, without touching visibility."""
1670 moderator, mod_token = generate_user(is_superuser=True)
1671 user1, token1 = generate_user()
1672 user2, _ = generate_user()
1673 state_id = create_test_host_request_with_moderation(token1, user2.id)
1674 queue_item_id = _open_queue_item_id(state_id)
1676 with real_moderation_session(mod_token) as api:
1677 api.ModerateContent(
1678 moderation_pb2.ModerateContentReq(
1679 moderation_state_id=state_id,
1680 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY,
1681 queue_item_id=queue_item_id,
1682 priority=10,
1683 reason="bump",
1684 )
1685 )
1687 with session_scope() as session:
1688 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
1689 assert queue_item.priority == 10
1690 assert queue_item.resolved_by_log_id is None # not resolved
1691 # State stays shadowed (visibility untouched)
1692 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.shadowed
1694 log = session.execute(
1695 select(ModerationLog)
1696 .where(ModerationLog.moderation_state_id == state_id)
1697 .where(ModerationLog.action == ModerationAction.set_priority)
1698 ).scalar_one()
1699 assert log.new_priority == 10
1700 assert log.queue_item_id == queue_item_id
1702 # GetModerationQueue surfaces the new priority
1703 with real_moderation_session(mod_token) as api:
1704 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1705 item = next(i for i in res.queue_items if i.queue_item_id == queue_item_id)
1706 assert item.priority == 10
1709def test_ModerateContent_unflag(db):
1710 """UNFLAG resolves a single named flag and does not change visibility."""
1711 moderator, mod_token = generate_user(is_superuser=True)
1712 user1, token1 = generate_user()
1713 user2, _ = generate_user()
1714 state_id = create_test_host_request_with_moderation(token1, user2.id)
1715 queue_item_id = _open_queue_item_id(state_id)
1717 with real_moderation_session(mod_token) as api:
1718 api.ModerateContent(
1719 moderation_pb2.ModerateContentReq(
1720 moderation_state_id=state_id,
1721 action=moderation_pb2.MODERATION_ACTION_UNFLAG,
1722 queue_item_id=queue_item_id,
1723 reason="dismissed",
1724 )
1725 )
1727 with session_scope() as session:
1728 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
1729 assert queue_item.resolved_by_log_id is not None
1730 # Visibility unchanged (still shadowed from creation)
1731 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.shadowed
1732 log = session.get_one(ModerationLog, queue_item.resolved_by_log_id)
1733 assert log.action == ModerationAction.unflag
1734 assert log.queue_item_id == queue_item_id
1737def test_ModerateContent_unflag_requires_queue_item(db):
1738 """UNFLAG without a queue_item_id is rejected."""
1739 moderator, mod_token = generate_user(is_superuser=True)
1740 user1, token1 = generate_user()
1741 user2, _ = generate_user()
1742 state_id = create_test_host_request_with_moderation(token1, user2.id)
1744 with real_moderation_session(mod_token) as api:
1745 with pytest.raises(grpc.RpcError) as e:
1746 api.ModerateContent(
1747 moderation_pb2.ModerateContentReq(
1748 moderation_state_id=state_id,
1749 action=moderation_pb2.MODERATION_ACTION_UNFLAG,
1750 reason="no target",
1751 )
1752 )
1753 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1756def test_ModerateContent_approve_without_clear_flags_leaves_flag_open(db):
1757 """APPROVE without clear_flags changes visibility but leaves open flags in the queue."""
1758 moderator, mod_token = generate_user(is_superuser=True)
1759 user1, token1 = generate_user()
1760 user2, _ = generate_user()
1761 state_id = create_test_host_request_with_moderation(token1, user2.id)
1762 queue_item_id = _open_queue_item_id(state_id)
1764 with real_moderation_session(mod_token) as api:
1765 api.ModerateContent(
1766 moderation_pb2.ModerateContentReq(
1767 moderation_state_id=state_id,
1768 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1769 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1770 reason="visible but still under review",
1771 )
1772 )
1774 with session_scope() as session:
1775 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.visible
1776 assert session.get_one(ModerationQueueItem, queue_item_id).resolved_by_log_id is None
1779def test_ModerateContent_flag_supersede(db):
1780 """FLAG with supersede_queue_item_id resolves the named flag as the new one opens."""
1781 moderator, mod_token = generate_user(is_superuser=True)
1782 user1, token1 = generate_user()
1783 user2, _ = generate_user()
1784 state_id = create_test_host_request_with_moderation(token1, user2.id)
1785 initial_item_id = _open_queue_item_id(state_id)
1787 with real_moderation_session(mod_token) as api:
1788 api.ModerateContent(
1789 moderation_pb2.ModerateContentReq(
1790 moderation_state_id=state_id,
1791 action=moderation_pb2.MODERATION_ACTION_FLAG,
1792 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG,
1793 priority=3,
1794 supersede_queue_item_id=initial_item_id,
1795 reason="machine re-flag",
1796 )
1797 )
1799 with session_scope() as session:
1800 # Old initial_review flag is resolved
1801 assert session.get_one(ModerationQueueItem, initial_item_id).resolved_by_log_id is not None
1802 # Exactly one open flag remains: the new machine_flag at priority 3
1803 open_items = (
1804 session.execute(
1805 select(ModerationQueueItem)
1806 .where(ModerationQueueItem.moderation_state_id == state_id)
1807 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1808 )
1809 .scalars()
1810 .all()
1811 )
1812 assert len(open_items) == 1
1813 assert open_items[0].trigger == ModerationTrigger.machine_flag
1814 assert open_items[0].priority == 3
1817def test_GetModerationQueue_filter_by_priority(db):
1818 """priority_min / priority_max filter the queue by priority range."""
1819 moderator, mod_token = generate_user(is_superuser=True)
1820 user1, token1 = generate_user()
1821 user2, _ = generate_user()
1822 state_id = create_test_host_request_with_moderation(token1, user2.id)
1823 queue_item_id = _open_queue_item_id(state_id)
1825 with real_moderation_session(mod_token) as api:
1826 api.ModerateContent(
1827 moderation_pb2.ModerateContentReq(
1828 moderation_state_id=state_id,
1829 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY,
1830 queue_item_id=queue_item_id,
1831 priority=7,
1832 reason="raise",
1833 )
1834 )
1836 # priority_min excludes it below the threshold, includes at/above
1837 assert len(api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_min=8)).queue_items) == 0
1838 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_min=7))
1839 assert [i.queue_item_id for i in res.queue_items] == [queue_item_id]
1841 # priority_max excludes it above the threshold
1842 assert len(api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_max=6)).queue_items) == 0
1843 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_max=7))
1844 assert [i.queue_item_id for i in res.queue_items] == [queue_item_id]
1847# ============================================================================
1848# Tests for group chat moderation
1849# ============================================================================
1852def test_group_chat_created_with_moderation_state(db):
1853 """Test that group chats are created with moderation state"""
1854 user1, token1 = generate_user()
1855 user2, _ = generate_user()
1856 make_friends(user1, user2)
1858 with conversations_session(token1) as api:
1859 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1860 group_chat_id = res.group_chat_id
1862 # Verify moderation state was created
1863 with session_scope() as session:
1864 group_chat = session.execute(select(GroupChat).where(GroupChat.conversation_id == group_chat_id)).scalar_one()
1866 assert group_chat.moderation_state.object_type == ModerationObjectType.group_chat
1867 assert group_chat.moderation_state.object_id == group_chat_id
1868 # Group chats start as SHADOWED
1869 assert group_chat.moderation_state.visibility == ModerationVisibility.shadowed
1871 # A moderation queue item should have been created
1872 queue_item = (
1873 session.execute(
1874 select(ModerationQueueItem).where(
1875 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id
1876 )
1877 )
1878 .scalars()
1879 .first()
1880 )
1881 assert queue_item is not None
1882 assert queue_item.trigger == ModerationTrigger.initial_review
1885def test_group_chat_GetModerationState(db):
1886 """Test GetModerationState API for group chats"""
1887 user1, token1 = generate_user()
1888 user2, _ = generate_user()
1889 moderator, mod_token = generate_user(is_superuser=True)
1890 make_friends(user1, user2)
1892 with conversations_session(token1) as api:
1893 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1894 group_chat_id = res.group_chat_id
1896 # Moderator can look up the moderation state
1897 with real_moderation_session(mod_token) as api:
1898 res = api.GetModerationState(
1899 moderation_pb2.GetModerationStateReq(
1900 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1901 object_id=group_chat_id,
1902 )
1903 )
1904 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT
1905 assert res.moderation_state.object_id == group_chat_id
1906 # Starts as SHADOWED
1907 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1910def test_group_chat_moderation_hide(db):
1911 """Test that a moderator can hide a group chat and participants can no longer see it"""
1912 user1, token1 = generate_user()
1913 user2, token2 = generate_user()
1914 moderator, mod_token = generate_user(is_superuser=True)
1915 make_friends(user1, user2)
1917 with conversations_session(token1) as api:
1918 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1919 group_chat_id = res.group_chat_id
1920 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
1922 # First approve the group chat so both users can see it
1923 with real_moderation_session(mod_token) as api:
1924 state_res = api.GetModerationState(
1925 moderation_pb2.GetModerationStateReq(
1926 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1927 object_id=group_chat_id,
1928 )
1929 )
1930 api.ModerateContent(
1931 moderation_pb2.ModerateContentReq(
1932 moderation_state_id=state_res.moderation_state.moderation_state_id,
1933 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1934 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1935 reason="Approved",
1936 )
1937 )
1939 # Both users can see the chat now
1940 with conversations_session(token1) as api:
1941 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1942 assert len(res.group_chats) == 1
1944 with conversations_session(token2) as api:
1945 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1946 assert len(res.group_chats) == 1
1948 # Moderator hides the group chat
1949 with real_moderation_session(mod_token) as api:
1950 state_res = api.GetModerationState(
1951 moderation_pb2.GetModerationStateReq(
1952 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1953 object_id=group_chat_id,
1954 )
1955 )
1956 api.ModerateContent(
1957 moderation_pb2.ModerateContentReq(
1958 moderation_state_id=state_res.moderation_state.moderation_state_id,
1959 action=moderation_pb2.MODERATION_ACTION_HIDE,
1960 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1961 reason="Inappropriate content",
1962 )
1963 )
1965 # Neither user can see the chat now
1966 with conversations_session(token1) as api:
1967 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1968 assert len(res.group_chats) == 0
1970 with conversations_session(token2) as api:
1971 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1972 assert len(res.group_chats) == 0
1974 # Trying to get messages returns empty (chat is hidden so no messages visible)
1975 with conversations_session(token1) as api:
1976 res = api.GetGroupChatMessages(conversations_pb2.GetGroupChatMessagesReq(group_chat_id=group_chat_id))
1977 assert len(res.messages) == 0
1980def test_group_chat_moderation_shadow(db):
1981 """Test that shadowing a group chat hides it from non-creator participants"""
1982 user1, token1 = generate_user() # Creator
1983 user2, token2 = generate_user() # Participant
1984 moderator, mod_token = generate_user(is_superuser=True)
1985 make_friends(user1, user2)
1987 with conversations_session(token1) as api:
1988 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1989 group_chat_id = res.group_chat_id
1990 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
1992 # Moderator shadows the group chat
1993 with real_moderation_session(mod_token) as api:
1994 state_res = api.GetModerationState(
1995 moderation_pb2.GetModerationStateReq(
1996 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1997 object_id=group_chat_id,
1998 )
1999 )
2000 api.ModerateContent(
2001 moderation_pb2.ModerateContentReq(
2002 moderation_state_id=state_res.moderation_state.moderation_state_id,
2003 action=moderation_pb2.MODERATION_ACTION_HIDE,
2004 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2005 reason="Needs review",
2006 )
2007 )
2009 # Creator can see SHADOWED content in list operations
2010 with conversations_session(token1) as api:
2011 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2012 assert len(res.group_chats) == 1
2013 assert res.group_chats[0].group_chat_id == group_chat_id
2015 # But non-creator participant cannot see it in lists
2016 with conversations_session(token2) as api:
2017 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2018 assert len(res.group_chats) == 0
2020 # Creator can also access it directly via GetGroupChat
2021 with conversations_session(token1) as api:
2022 res = api.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id))
2023 assert res.group_chat_id == group_chat_id
2026# ============================================================================
2027# Tests for auto-approval background job
2028# ============================================================================
2031def test_auto_approve_moderation_queue_disabled_when_zero(db, email_collector: EmailCollector):
2032 """Test that auto-approval is disabled when deadline is 0"""
2033 moderator, mod_token = generate_user(is_superuser=True)
2034 user1, token1 = generate_user()
2035 user2, token2 = generate_user()
2037 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2038 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2040 # Create a host request
2041 with requests_session(token1) as api:
2042 host_request_id = api.CreateHostRequest(
2043 requests_pb2.CreateHostRequestReq(
2044 host_user_id=user2.id,
2045 from_date=today_plus_2,
2046 to_date=today_plus_3,
2047 text=valid_request_text(),
2048 )
2049 ).host_request_id
2051 # No email should have been sent (request is shadowed)
2052 assert email_collector.count_for_recipient(user2.email) == 0
2054 # Ensure deadline is 0 (disabled)
2055 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 0
2057 # Run the job
2058 auto_approve_moderation_queue(empty_pb2.Empty())
2060 # Surfer (author) can see the request via API
2061 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2062 assert res.host_request_id == host_request_id
2064 # Author can see their SHADOWED request in their sent list
2065 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
2066 assert len(res.host_requests) == 1
2067 assert res.host_requests[0].host_request_id == host_request_id
2069 # Host cannot see the request (it's shadowed from them)
2070 with requests_session(token2) as api:
2071 with pytest.raises(grpc.RpcError) as e:
2072 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2073 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2075 # Host doesn't see it in their received list either
2076 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2077 assert len(res.host_requests) == 0
2079 # Moderator can still see the item in the moderation queue
2080 with real_moderation_session(mod_token) as api:
2081 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2082 assert len(res.queue_items) == 1
2083 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW
2085 # Moderator can check the state is still SHADOWED
2086 state_res = api.GetModerationState(
2087 moderation_pb2.GetModerationStateReq(
2088 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2089 object_id=host_request_id,
2090 )
2091 )
2092 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2095def test_auto_approve_moderation_queue_approves_old_items(
2096 db, email_collector: EmailCollector, push_collector: PushCollector
2097):
2098 """Test that auto-approval approves items older than the deadline"""
2099 moderator, mod_token = generate_user(is_superuser=True)
2100 user1, token1 = generate_user()
2101 user2, token2 = generate_user()
2103 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2104 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2106 # Create a host request
2107 with requests_session(token1) as api:
2108 host_request_id = api.CreateHostRequest(
2109 requests_pb2.CreateHostRequestReq(
2110 host_user_id=user2.id,
2111 from_date=today_plus_2,
2112 to_date=today_plus_3,
2113 text=valid_request_text("Test request for auto-approval"),
2114 )
2115 ).host_request_id
2117 # No email sent initially (shadowed)
2118 assert email_collector.count_for_recipient(user2.email) == 0
2120 # Host cannot see the request yet
2121 with requests_session(token2) as api:
2122 with pytest.raises(grpc.RpcError) as e:
2123 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2124 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2126 # Make the queue item appear old by backdating its time_created
2127 with session_scope() as session:
2128 host_request = session.execute(
2129 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
2130 ).scalar_one()
2131 queue_item = session.execute(
2132 select(ModerationQueueItem)
2133 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
2134 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2135 ).scalar_one()
2136 # Backdate the queue item by 2 minutes
2137 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=2)
2139 # Set deadline to 60 seconds (items older than 60 seconds will be auto-approved)
2140 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2141 config.MODERATION_BOT_USER_ID = moderator.id
2143 # Run the job
2144 auto_approve_moderation_queue(empty_pb2.Empty())
2146 # Now host can see the request via API
2147 with requests_session(token2) as api:
2148 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2149 assert res.host_request_id == host_request_id
2150 assert res.latest_message.text.text == valid_request_text("Test request for auto-approval")
2152 # Host sees it in their received list
2153 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2154 assert len(res.host_requests) == 1
2155 assert res.host_requests[0].host_request_id == host_request_id
2157 # Surfer sees it in their sent list
2158 with requests_session(token1) as api:
2159 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
2160 assert len(res.host_requests) == 1
2161 assert res.host_requests[0].host_request_id == host_request_id
2163 with real_moderation_session(mod_token) as api:
2164 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2165 assert len(res.queue_items) == 1
2166 flag_item = res.queue_items[0]
2167 assert flag_item.trigger == moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG
2168 assert flag_item.priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY
2170 state_res = api.GetModerationState(
2171 moderation_pb2.GetModerationStateReq(
2172 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2173 object_id=host_request_id,
2174 )
2175 )
2176 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
2178 log_res = api.GetModerationLog(
2179 moderation_pb2.GetModerationLogReq(moderation_state_id=state_res.moderation_state.moderation_state_id)
2180 )
2181 approve_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_APPROVE]
2182 assert len(approve_entries) == 1
2183 assert "Auto-approved" in approve_entries[0].reason
2184 assert "60 seconds" in approve_entries[0].reason
2185 assert approve_entries[0].moderator_user_id == moderator.id
2187 flag_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_FLAG]
2188 assert len(flag_entries) == 1
2189 assert flag_entries[0].moderator_user_id == moderator.id
2192def test_auto_approve_does_not_approve_recent_items(db, email_collector: EmailCollector):
2193 """Test that auto-approval does not approve items that are newer than the deadline"""
2194 moderator, mod_token = generate_user(is_superuser=True)
2195 user1, token1 = generate_user()
2196 user2, token2 = generate_user()
2198 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2199 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2201 # Create a host request
2202 with requests_session(token1) as api:
2203 host_request_id = api.CreateHostRequest(
2204 requests_pb2.CreateHostRequestReq(
2205 host_user_id=user2.id,
2206 from_date=today_plus_2,
2207 to_date=today_plus_3,
2208 text=valid_request_text(),
2209 )
2210 ).host_request_id
2212 # No email sent (shadowed)
2213 assert email_collector.count_for_recipient(user2.email) == 0
2215 # Set deadline to 1 hour (items older than 1 hour will be auto-approved)
2216 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 3600
2217 config.MODERATION_BOT_USER_ID = moderator.id
2219 # Run the job - the item was just created, so it shouldn't be approved
2220 auto_approve_moderation_queue(empty_pb2.Empty())
2222 # Still no email sent
2223 assert email_collector.count_for_recipient(user2.email) == 0
2225 # Host still cannot see the request
2226 with requests_session(token2) as api:
2227 with pytest.raises(grpc.RpcError) as e:
2228 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2229 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2231 # Not in host's received list
2232 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2233 assert len(res.host_requests) == 0
2235 # Moderator sees it still in queue unresolved
2236 with real_moderation_session(mod_token) as api:
2237 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2238 assert len(res.queue_items) == 1
2240 # State is still SHADOWED
2241 state_res = api.GetModerationState(
2242 moderation_pb2.GetModerationStateReq(
2243 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2244 object_id=host_request_id,
2245 )
2246 )
2247 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2250def test_auto_approve_does_not_approve_already_approved(db):
2251 """Test that auto-approval does not re-approve already visible content"""
2252 moderator, mod_token = generate_user(is_superuser=True)
2253 user1, token1 = generate_user()
2254 user2, token2 = generate_user()
2256 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2257 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2259 # Create a host request
2260 with requests_session(token1) as api:
2261 host_request_id = api.CreateHostRequest(
2262 requests_pb2.CreateHostRequestReq(
2263 host_user_id=user2.id,
2264 from_date=today_plus_2,
2265 to_date=today_plus_3,
2266 text=valid_request_text(),
2267 )
2268 ).host_request_id
2270 # Moderator approves it manually
2271 with real_moderation_session(mod_token) as api:
2272 state_res = api.GetModerationState(
2273 moderation_pb2.GetModerationStateReq(
2274 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2275 object_id=host_request_id,
2276 )
2277 )
2278 state_id = state_res.moderation_state.moderation_state_id
2280 api.ModerateContent(
2281 moderation_pb2.ModerateContentReq(
2282 moderation_state_id=state_id,
2283 action=moderation_pb2.MODERATION_ACTION_APPROVE,
2284 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2285 reason="Approved by moderator",
2286 clear_flags=True,
2287 )
2288 )
2290 # Host can now see it
2291 with requests_session(token2) as api:
2292 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2293 assert res.host_request_id == host_request_id
2295 # Get log count before auto-approval
2296 with real_moderation_session(mod_token) as api:
2297 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2298 log_count_before = len(log_res_before.log_entries)
2300 # Set deadline to 1 second
2301 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1
2302 config.MODERATION_BOT_USER_ID = moderator.id
2304 # Run the job
2305 auto_approve_moderation_queue(empty_pb2.Empty())
2307 # No new log entries should be created (already approved, queue item resolved)
2308 with real_moderation_session(mod_token) as api:
2309 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2310 assert len(log_res_after.log_entries) == log_count_before
2312 # Queue should be empty (item was resolved when moderator approved)
2313 queue_res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2314 assert len(queue_res.queue_items) == 0
2317def test_auto_approve_does_not_approve_moderator_shadowed_items(db):
2318 """Test that auto-approval does not approve items that were explicitly shadowed by a moderator"""
2319 moderator, mod_token = generate_user(is_superuser=True)
2320 user1, token1 = generate_user()
2321 user2, token2 = generate_user()
2323 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2324 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2326 # Create a host request
2327 with requests_session(token1) as api:
2328 host_request_id = api.CreateHostRequest(
2329 requests_pb2.CreateHostRequestReq(
2330 host_user_id=user2.id,
2331 from_date=today_plus_2,
2332 to_date=today_plus_3,
2333 text=valid_request_text(),
2334 )
2335 ).host_request_id
2337 # Moderator explicitly shadows the content (keeping it shadowed but resolving the queue item)
2338 with real_moderation_session(mod_token) as api:
2339 state_res = api.GetModerationState(
2340 moderation_pb2.GetModerationStateReq(
2341 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2342 object_id=host_request_id,
2343 )
2344 )
2345 state_id = state_res.moderation_state.moderation_state_id
2347 # Set to SHADOWED explicitly with clear_flags - this resolves the INITIAL_REVIEW queue item
2348 api.ModerateContent(
2349 moderation_pb2.ModerateContentReq(
2350 moderation_state_id=state_id,
2351 action=moderation_pb2.MODERATION_ACTION_HIDE,
2352 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2353 reason="Keeping shadowed for review",
2354 clear_flags=True,
2355 )
2356 )
2358 # Backdate to ensure it would be old enough for auto-approval
2359 with session_scope() as session:
2360 queue_item = session.execute(
2361 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
2362 ).scalar_one()
2363 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10)
2365 # Set deadline to 1 second
2366 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1
2367 config.MODERATION_BOT_USER_ID = moderator.id
2369 # Get log count before
2370 with real_moderation_session(mod_token) as api:
2371 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2372 log_count_before = len(log_res_before.log_entries)
2374 # Run the job
2375 auto_approve_moderation_queue(empty_pb2.Empty())
2377 # No new log entries - the queue item was resolved when moderator shadowed it
2378 with real_moderation_session(mod_token) as api:
2379 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2380 assert len(log_res_after.log_entries) == log_count_before
2382 # State should still be SHADOWED (not auto-approved to VISIBLE)
2383 state_res = api.GetModerationState(
2384 moderation_pb2.GetModerationStateReq(
2385 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2386 object_id=host_request_id,
2387 )
2388 )
2389 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2391 # Host still cannot see the request
2392 with requests_session(token2) as api:
2393 with pytest.raises(grpc.RpcError) as e:
2394 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2395 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2398def test_auto_approve_skips_shadowed_user_authored_items(db):
2399 """Auto-approval must not promote content authored by a currently-shadowed user."""
2400 moderator, mod_token = generate_user(is_superuser=True)
2401 surfer, surfer_token = generate_user()
2402 host, host_token = generate_user()
2404 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2405 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2407 with requests_session(surfer_token) as api:
2408 host_request_id = api.CreateHostRequest(
2409 requests_pb2.CreateHostRequestReq(
2410 host_user_id=host.id,
2411 from_date=today_plus_2,
2412 to_date=today_plus_3,
2413 text=valid_request_text(),
2414 )
2415 ).host_request_id
2417 # Shadow the surfer after the host request was created
2418 with session_scope() as session:
2419 session.execute(select(User).where(User.id == surfer.id)).scalar_one().shadowed_at = now()
2421 # Backdate the queue item to make it eligible for auto-approval
2422 with session_scope() as session:
2423 host_request = session.execute(
2424 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
2425 ).scalar_one()
2426 queue_item = session.execute(
2427 select(ModerationQueueItem)
2428 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
2429 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2430 ).scalar_one()
2431 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10)
2433 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2434 config.MODERATION_BOT_USER_ID = moderator.id
2436 auto_approve_moderation_queue(empty_pb2.Empty())
2438 # State should remain SHADOWED — the auto-approve job must skip shadowed-user content
2439 with real_moderation_session(mod_token) as api:
2440 state_res = api.GetModerationState(
2441 moderation_pb2.GetModerationStateReq(
2442 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2443 object_id=host_request_id,
2444 )
2445 )
2446 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2448 # Host still cannot see the request
2449 with requests_session(host_token) as api:
2450 with pytest.raises(grpc.RpcError) as e:
2451 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2452 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2454 # But the (shadowed) author can still see their own request
2455 with requests_session(surfer_token) as api:
2456 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2457 assert res.host_request_id == host_request_id
2460def test_auto_approve_preserves_other_open_flags(db):
2461 """Auto-approval supersedes only the INITIAL_REVIEW item; other open flags survive and a high-prio flag is added."""
2462 moderator, mod_token = generate_user(is_superuser=True)
2463 surfer, surfer_token = generate_user()
2464 host, _ = generate_user()
2466 state_id = create_test_host_request_with_moderation(surfer_token, host.id)
2468 with real_moderation_session(mod_token) as api:
2469 api.ModerateContent(
2470 moderation_pb2.ModerateContentReq(
2471 moderation_state_id=state_id,
2472 action=moderation_pb2.MODERATION_ACTION_FLAG,
2473 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
2474 priority=5,
2475 reason="user reported this",
2476 )
2477 )
2479 with session_scope() as session:
2480 initial_item = session.execute(
2481 select(ModerationQueueItem)
2482 .where(ModerationQueueItem.moderation_state_id == state_id)
2483 .where(ModerationQueueItem.trigger == ModerationTrigger.initial_review)
2484 ).scalar_one()
2485 initial_item_id = initial_item.id
2486 initial_item.time_created = datetime.now(initial_item.time_created.tzinfo) - timedelta(minutes=10)
2488 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2489 config.MODERATION_BOT_USER_ID = moderator.id
2491 auto_approve_moderation_queue(empty_pb2.Empty())
2493 with session_scope() as session:
2494 assert (
2495 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.id == initial_item_id))
2496 .scalar_one()
2497 .resolved_by_log_id
2498 is not None
2499 )
2500 assert (
2501 session.execute(select(ModerationState).where(ModerationState.id == state_id)).scalar_one().visibility
2502 == ModerationVisibility.visible
2503 )
2505 open_items = (
2506 session.execute(
2507 select(ModerationQueueItem)
2508 .where(ModerationQueueItem.moderation_state_id == state_id)
2509 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2510 )
2511 .scalars()
2512 .all()
2513 )
2514 open_by_trigger = {item.trigger: item for item in open_items}
2515 assert open_by_trigger.keys() == {ModerationTrigger.user_flag, ModerationTrigger.machine_flag}
2516 assert open_by_trigger[ModerationTrigger.user_flag].priority == 5
2517 assert open_by_trigger[ModerationTrigger.machine_flag].priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY
2520# ============================================================================
2521# Notification Suppression Tests
2522# ============================================================================
2525def test_host_request_message_notifications_suppressed_before_approval(
2526 db, email_collector: EmailCollector, push_collector: PushCollector, moderator
2527):
2528 """
2529 Test that notifications are NOT sent for messages in host requests
2530 that haven't been approved yet.
2531 """
2532 host, host_token = generate_user(complete_profile=True)
2533 surfer, surfer_token = generate_user(complete_profile=True)
2535 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2536 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2538 # Create host request (it starts in SHADOWED state)
2539 with requests_session(surfer_token) as api:
2540 hr_id = api.CreateHostRequest(
2541 requests_pb2.CreateHostRequestReq(
2542 host_user_id=host.id,
2543 from_date=today_plus_2,
2544 to_date=today_plus_3,
2545 text=valid_request_text("Initial request message"),
2546 )
2547 ).host_request_id
2549 # No notifications should have been sent to the host (request is SHADOWED)
2550 assert email_collector.count_for_recipient(host.email) == 0
2551 assert push_collector.count_for_user(host.id) == 0
2553 # Send additional messages BEFORE approval - should NOT generate notifications
2554 with requests_session(surfer_token) as api:
2555 api.SendHostRequestMessage(
2556 requests_pb2.SendHostRequestMessageReq(
2557 host_request_id=hr_id,
2558 text="Follow-up message 1",
2559 )
2560 )
2561 api.SendHostRequestMessage(
2562 requests_pb2.SendHostRequestMessageReq(
2563 host_request_id=hr_id,
2564 text="Follow-up message 2",
2565 )
2566 )
2568 # Host should STILL have no notifications (messages sent while SHADOWED)
2569 assert email_collector.count_for_recipient(host.email) == 0
2570 assert push_collector.count_for_user(host.id) == 0
2572 # Now approve the request
2573 moderator.approve_host_request(hr_id)
2574 email_collector.pop_for_recipient(host.email)
2576 # Host should now have 3 notifications (all deferred notifications are delivered on approval):
2577 # 1. host_request:create (the initial request)
2578 # 2. host_request:message (Follow-up message 1)
2579 # 3. host_request:message (Follow-up message 2)
2580 assert push_collector.count_for_user(host.id) == 3
2581 push = push_collector.pop_for_user(host.id, last=False)
2582 assert push.content.title == f"New host request from {surfer.name}"
2585def test_host_request_status_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2586 """
2587 Test that status change notifications (accept/reject/etc.) are NOT sent
2588 for host requests that haven't been approved yet.
2590 Note: In practice, the host can't even SEE the request to accept/reject it
2591 when it's SHADOWED. But if they somehow did, we still shouldn't notify.
2592 """
2593 host, host_token = generate_user(complete_profile=True)
2594 surfer, surfer_token = generate_user(complete_profile=True)
2596 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2597 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2599 # Create host request
2600 with requests_session(surfer_token) as api:
2601 hr_id = api.CreateHostRequest(
2602 requests_pb2.CreateHostRequestReq(
2603 host_user_id=host.id,
2604 from_date=today_plus_2,
2605 to_date=today_plus_3,
2606 text=valid_request_text(),
2607 )
2608 ).host_request_id
2610 # No notifications should have been sent to the host (request is SHADOWED)
2611 assert push_collector.count_for_user(host.id) == 0
2613 # The surfer can cancel their own request even when SHADOWED
2614 # But this should NOT notify the host since the request isn't approved
2615 with requests_session(surfer_token) as api:
2616 api.RespondHostRequest(
2617 requests_pb2.RespondHostRequestReq(
2618 host_request_id=hr_id,
2619 status=messages_pb2.HOST_REQUEST_STATUS_CANCELLED,
2620 text="Actually, never mind",
2621 )
2622 )
2624 # Host should STILL have no notifications (cancel notification suppressed)
2625 assert push_collector.count_for_user(host.id) == 0
2628def test_host_request_notifications_sent_after_approval(
2629 db, email_collector: EmailCollector, push_collector: PushCollector, moderator
2630):
2631 """
2632 Test that after a host request is approved, all notifications work normally.
2633 """
2634 host, host_token = generate_user(complete_profile=True)
2635 surfer, surfer_token = generate_user(complete_profile=True)
2637 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2638 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2640 # Create and approve host request
2641 with requests_session(surfer_token) as api:
2642 hr_id = api.CreateHostRequest(
2643 requests_pb2.CreateHostRequestReq(
2644 host_user_id=host.id,
2645 from_date=today_plus_2,
2646 to_date=today_plus_3,
2647 text=valid_request_text(),
2648 )
2649 ).host_request_id
2651 moderator.approve_host_request(hr_id)
2653 # Host should have received 1 notification (the approval notification)
2654 email_collector.pop_for_recipient(host.email, last=True)
2655 push_collector.pop_for_user(host.id, last=True)
2657 # Host accepts the request - surfer should be notified
2658 with requests_session(host_token) as api:
2659 api.RespondHostRequest(
2660 requests_pb2.RespondHostRequestReq(
2661 host_request_id=hr_id,
2662 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED,
2663 text="Sure, come on over!",
2664 )
2665 )
2667 # Surfer should have 1 notification (the accept notification)
2668 email_collector.pop_for_recipient(surfer.email, last=True)
2669 push = push_collector.pop_for_user(surfer.id, last=True)
2670 assert push.content.title == f"{host.name} accepted your host request"
2672 # Surfer confirms - host should be notified
2673 with requests_session(surfer_token) as api:
2674 api.RespondHostRequest(
2675 requests_pb2.RespondHostRequestReq(
2676 host_request_id=hr_id,
2677 status=messages_pb2.HOST_REQUEST_STATUS_CONFIRMED,
2678 text="See you then!",
2679 )
2680 )
2682 # Host should now have received the confirmation notifications
2683 email_collector.pop_for_recipient(host.email, last=True)
2684 push = push_collector.pop_for_user(host.id, last=True)
2685 assert push.content.title == f"{surfer.name} confirmed their host request"
2688def test_group_chat_message_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2689 """
2690 Test that notifications are NOT sent for messages in group chats
2691 that haven't been approved yet.
2692 """
2693 user1, token1 = generate_user(complete_profile=True)
2694 user2, token2 = generate_user(complete_profile=True)
2696 # Create a group chat (starts in SHADOWED state)
2697 with conversations_session(token1) as api:
2698 res = api.CreateGroupChat(
2699 conversations_pb2.CreateGroupChatReq(
2700 recipient_user_ids=[user2.id],
2701 )
2702 )
2703 gc_id = res.group_chat_id
2705 # Verify initial state
2706 with session_scope() as session:
2707 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2708 assert gc.moderation_state.visibility == ModerationVisibility.shadowed
2710 # No notifications should have been sent yet (chat is SHADOWED)
2711 assert push_collector.count_for_user(user2.id) == 0
2713 # Send messages BEFORE approval
2714 with conversations_session(token1) as api:
2715 api.SendMessage(
2716 conversations_pb2.SendMessageReq(
2717 group_chat_id=gc_id,
2718 text="Hello before approval",
2719 )
2720 )
2722 # Process the queued notification job
2723 while process_job():
2724 pass
2726 # User2 should STILL have no notifications (chat is SHADOWED)
2727 assert push_collector.count_for_user(user2.id) == 0
2729 # Now approve the group chat
2730 moderator.approve_group_chat(gc_id)
2732 # Process the queued notification jobs from approval
2733 while process_job():
2734 pass
2736 # Verify moderation state after approval
2737 with session_scope() as session:
2738 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2739 assert gc.moderation_state.visibility == ModerationVisibility.visible
2741 # User2 should have received 1 notification for the first message sent before approval
2742 push = push_collector.pop_for_user(user2.id, last=True)
2743 assert push.content.title == user1.name
2744 assert push.content.body == "Hello before approval"
2746 # Send a message AFTER approval
2747 with conversations_session(token1) as api:
2748 api.SendMessage(
2749 conversations_pb2.SendMessageReq(
2750 group_chat_id=gc_id,
2751 text="Hello after approval",
2752 )
2753 )
2755 # Process the queued notification job
2756 while process_job():
2757 pass
2759 # User2 should have received another notification
2760 assert push_collector.count_for_user(user2.id) == 1
2763def test_event_moderation_state_content(db):
2764 """Test that event moderation state content includes both title and description"""
2765 super_user, super_token = generate_user(is_superuser=True)
2766 user, token = generate_user()
2768 with session_scope() as session:
2769 create_community(session, 0, 2, "Community", [user], [], None)
2771 start_time = now() + timedelta(hours=2)
2772 end_time = start_time + timedelta(hours=3)
2774 with events_session(token) as api:
2775 res = api.CreateEvent(
2776 events_pb2.CreateEventReq(
2777 title="My Event Title",
2778 content="My event description.",
2779 photo_key=None,
2780 location=events_pb2.EventLocation(
2781 address="Near Null Island",
2782 lat=0.1,
2783 lng=0.2,
2784 ),
2785 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time),
2786 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time),
2787 )
2788 )
2789 event_id = res.event_id
2791 with session_scope() as session:
2792 occurrence = session.execute(select(EventOccurrence).where(EventOccurrence.id == event_id)).scalar_one()
2793 state_id = occurrence.moderation_state_id
2795 with real_moderation_session(super_token) as api:
2796 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
2797 event_items = [
2798 item
2799 for item in res.queue_items
2800 if item.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE
2801 ]
2802 assert len(event_items) == 1
2803 assert event_items[0].moderation_state.content == "My Event Title\n\nMy event description."
2806# ============================================================================
2807# Tests for SetUserContentVisibility
2808# ============================================================================
2811def _get_moderation_state(session, object_type, object_id):
2812 return session.execute(
2813 select(ModerationState)
2814 .where(ModerationState.object_type == object_type)
2815 .where(ModerationState.object_id == object_id)
2816 ).scalar_one()
2819def test_SetUserContentVisibility_host_request(db):
2820 super_user, super_token = generate_user(is_superuser=True)
2821 surfer, surfer_token = generate_user()
2822 host, _ = generate_user()
2824 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2825 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2826 with requests_session(surfer_token) as api:
2827 hr_id = api.CreateHostRequest(
2828 requests_pb2.CreateHostRequestReq(
2829 host_user_id=host.id,
2830 from_date=today_plus_2,
2831 to_date=today_plus_3,
2832 text=valid_request_text(),
2833 )
2834 ).host_request_id
2836 with real_moderation_session(super_token) as api:
2837 res = api.SetUserContentVisibility(
2838 moderation_pb2.SetUserContentVisibilityReq(
2839 user_id=surfer.id,
2840 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2841 )
2842 )
2843 # Already shadowed by default — no-op
2844 assert res.updated_count == 0
2846 with real_moderation_session(super_token) as api:
2847 res = api.SetUserContentVisibility(
2848 moderation_pb2.SetUserContentVisibilityReq(
2849 user_id=surfer.id,
2850 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2851 reason="policy violation",
2852 )
2853 )
2854 assert res.updated_count == 1
2856 with session_scope() as session:
2857 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
2858 assert state.visibility == ModerationVisibility.hidden
2860 log_entries = (
2861 session.execute(
2862 select(ModerationLog)
2863 .where(ModerationLog.moderation_state_id == state.id)
2864 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc())
2865 )
2866 .scalars()
2867 .all()
2868 )
2869 # create log + bulk update log
2870 assert len(log_entries) == 2
2871 assert log_entries[-1].new_visibility == ModerationVisibility.hidden
2872 assert log_entries[-1].moderator_user_id == super_user.id
2873 assert log_entries[-1].reason == "policy violation"
2876def test_SetUserContentVisibility_group_chat(db):
2877 super_user, super_token = generate_user(is_superuser=True)
2878 creator, creator_token = generate_user()
2879 other, _ = generate_user()
2880 make_friends(creator, other)
2882 with conversations_session(creator_token) as api:
2883 gc_id = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[other.id])).group_chat_id
2885 with real_moderation_session(super_token) as api:
2886 api.SetUserContentVisibility(
2887 moderation_pb2.SetUserContentVisibilityReq(
2888 user_id=creator.id,
2889 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2890 )
2891 )
2893 with session_scope() as session:
2894 state = _get_moderation_state(session, ModerationObjectType.group_chat, gc_id)
2895 assert state.visibility == ModerationVisibility.hidden
2898def test_SetUserContentVisibility_event_occurrence(db):
2899 super_user, super_token = generate_user(is_superuser=True)
2900 creator, creator_token = generate_user()
2902 with session_scope() as session:
2903 create_community(session, 0, 2, "Community", [creator], [], None)
2905 start_time = now() + timedelta(hours=2)
2906 end_time = start_time + timedelta(hours=3)
2907 with events_session(creator_token) as api:
2908 event_id = api.CreateEvent(
2909 events_pb2.CreateEventReq(
2910 title="Event",
2911 content="Event description.",
2912 photo_key=None,
2913 location=events_pb2.EventLocation(
2914 address="Near Null Island",
2915 lat=0.1,
2916 lng=0.2,
2917 ),
2918 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time),
2919 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time),
2920 )
2921 ).event_id
2923 with real_moderation_session(super_token) as api:
2924 res = api.SetUserContentVisibility(
2925 moderation_pb2.SetUserContentVisibilityReq(
2926 user_id=creator.id,
2927 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2928 )
2929 )
2930 assert res.updated_count == 1
2932 with session_scope() as session:
2933 state = _get_moderation_state(session, ModerationObjectType.event_occurrence, event_id)
2934 assert state.visibility == ModerationVisibility.hidden
2937def test_SetUserContentVisibility_friend_request(db):
2938 super_user, super_token = generate_user(is_superuser=True)
2939 sender, sender_token = generate_user()
2940 recipient, _ = generate_user()
2942 with api_session(sender_token) as api:
2943 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=recipient.id))
2945 with session_scope() as session:
2946 fr_id = session.execute(
2947 select(FriendRelationship.id).where(FriendRelationship.from_user_id == sender.id)
2948 ).scalar_one()
2950 with real_moderation_session(super_token) as api:
2951 res = api.SetUserContentVisibility(
2952 moderation_pb2.SetUserContentVisibilityReq(
2953 user_id=sender.id,
2954 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2955 )
2956 )
2957 assert res.updated_count == 1
2959 with session_scope() as session:
2960 state = _get_moderation_state(session, ModerationObjectType.friend_request, fr_id)
2961 assert state.visibility == ModerationVisibility.hidden
2964def test_SetUserContentVisibility_round_trip(db):
2965 super_user, super_token = generate_user(is_superuser=True)
2966 surfer, surfer_token = generate_user()
2967 host, _ = generate_user()
2969 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2970 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2971 with requests_session(surfer_token) as api:
2972 hr_id = api.CreateHostRequest(
2973 requests_pb2.CreateHostRequestReq(
2974 host_user_id=host.id,
2975 from_date=today_plus_2,
2976 to_date=today_plus_3,
2977 text=valid_request_text(),
2978 )
2979 ).host_request_id
2981 with real_moderation_session(super_token) as api:
2982 api.SetUserContentVisibility(
2983 moderation_pb2.SetUserContentVisibilityReq(
2984 user_id=surfer.id,
2985 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2986 reason="first",
2987 )
2988 )
2989 api.SetUserContentVisibility(
2990 moderation_pb2.SetUserContentVisibilityReq(
2991 user_id=surfer.id,
2992 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2993 reason="second",
2994 )
2995 )
2997 with session_scope() as session:
2998 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
2999 assert state.visibility == ModerationVisibility.visible
3001 bulk_log_entries = (
3002 session.execute(
3003 select(ModerationLog)
3004 .where(ModerationLog.moderation_state_id == state.id)
3005 .where(ModerationLog.reason.in_(("first", "second")))
3006 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc())
3007 )
3008 .scalars()
3009 .all()
3010 )
3011 assert [entry.new_visibility for entry in bulk_log_entries] == [
3012 ModerationVisibility.hidden,
3013 ModerationVisibility.visible,
3014 ]
3017def test_SetUserContentVisibility_resolves_queue_items(db):
3018 super_user, super_token = generate_user(is_superuser=True)
3019 surfer, surfer_token = generate_user()
3020 host, _ = generate_user()
3022 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3023 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3024 with requests_session(surfer_token) as api:
3025 hr_id = api.CreateHostRequest(
3026 requests_pb2.CreateHostRequestReq(
3027 host_user_id=host.id,
3028 from_date=today_plus_2,
3029 to_date=today_plus_3,
3030 text=valid_request_text(),
3031 )
3032 ).host_request_id
3034 with session_scope() as session:
3035 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3036 queue_item = session.execute(
3037 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id)
3038 ).scalar_one()
3039 assert queue_item.resolved_by_log_id is None
3041 with real_moderation_session(super_token) as api:
3042 api.SetUserContentVisibility(
3043 moderation_pb2.SetUserContentVisibilityReq(
3044 user_id=surfer.id,
3045 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3046 )
3047 )
3049 with session_scope() as session:
3050 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3051 queue_item = session.execute(
3052 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id)
3053 ).scalar_one()
3054 assert queue_item.resolved_by_log_id is not None
3057def test_SetUserContentVisibility_noop_when_matches(db):
3058 super_user, super_token = generate_user(is_superuser=True)
3059 surfer, surfer_token = generate_user()
3060 host, _ = generate_user()
3062 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3063 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3064 with requests_session(surfer_token) as api:
3065 api.CreateHostRequest(
3066 requests_pb2.CreateHostRequestReq(
3067 host_user_id=host.id,
3068 from_date=today_plus_2,
3069 to_date=today_plus_3,
3070 text=valid_request_text(),
3071 )
3072 )
3074 with session_scope() as session:
3075 log_count_before = len(session.execute(select(ModerationLog)).scalars().all())
3077 with real_moderation_session(super_token) as api:
3078 res = api.SetUserContentVisibility(
3079 moderation_pb2.SetUserContentVisibilityReq(
3080 user_id=surfer.id,
3081 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3082 )
3083 )
3084 assert res.updated_count == 0
3086 with session_scope() as session:
3087 log_count_after = len(session.execute(select(ModerationLog)).scalars().all())
3088 assert log_count_after == log_count_before
3091def test_SetUserContentVisibility_unspecified_rejected(db):
3092 super_user, super_token = generate_user(is_superuser=True)
3093 target, _ = generate_user()
3095 with real_moderation_session(super_token) as api:
3096 with pytest.raises(grpc.RpcError) as e:
3097 api.SetUserContentVisibility(
3098 moderation_pb2.SetUserContentVisibilityReq(
3099 user_id=target.id,
3100 visibility=moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED,
3101 )
3102 )
3103 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
3106def test_SetUserContentVisibility_non_admin_rejected(db):
3107 normal_user, normal_token = generate_user()
3108 target, _ = generate_user()
3110 with real_moderation_session(normal_token) as api:
3111 with pytest.raises(grpc.RpcError) as e:
3112 api.SetUserContentVisibility(
3113 moderation_pb2.SetUserContentVisibilityReq(
3114 user_id=target.id,
3115 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3116 )
3117 )
3118 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED
3121def test_SetUserContentVisibility_writes_admin_action(db):
3122 super_user, super_token = generate_user(is_superuser=True)
3123 surfer, surfer_token = generate_user()
3124 host, _ = generate_user()
3126 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3127 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3128 with requests_session(surfer_token) as api:
3129 api.CreateHostRequest(
3130 requests_pb2.CreateHostRequestReq(
3131 host_user_id=host.id,
3132 from_date=today_plus_2,
3133 to_date=today_plus_3,
3134 text=valid_request_text(),
3135 )
3136 )
3138 with real_moderation_session(super_token) as api:
3139 api.SetUserContentVisibility(
3140 moderation_pb2.SetUserContentVisibilityReq(
3141 user_id=surfer.id,
3142 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3143 reason="bulk hide",
3144 )
3145 )
3147 with session_scope() as session:
3148 actions = (
3149 session.execute(
3150 select(AdminAction)
3151 .where(AdminAction.target_user_id == surfer.id)
3152 .where(AdminAction.action_type == "set_user_content_visibility")
3153 )
3154 .scalars()
3155 .all()
3156 )
3157 assert len(actions) == 1
3158 assert actions[0].admin_user_id == super_user.id
3159 assert actions[0].tag == "hidden"
3160 assert actions[0].note == "bulk hide"
3163def test_SetUserContentVisibility_only_touches_target(db):
3164 super_user, super_token = generate_user(is_superuser=True)
3165 target, target_token = generate_user()
3166 other, other_token = generate_user()
3167 host, _ = generate_user()
3169 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3170 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3172 with requests_session(target_token) as api:
3173 target_hr_id = api.CreateHostRequest(
3174 requests_pb2.CreateHostRequestReq(
3175 host_user_id=host.id,
3176 from_date=today_plus_2,
3177 to_date=today_plus_3,
3178 text=valid_request_text(),
3179 )
3180 ).host_request_id
3182 with requests_session(other_token) as api:
3183 other_hr_id = api.CreateHostRequest(
3184 requests_pb2.CreateHostRequestReq(
3185 host_user_id=host.id,
3186 from_date=today_plus_2,
3187 to_date=today_plus_3,
3188 text=valid_request_text(),
3189 )
3190 ).host_request_id
3192 with real_moderation_session(super_token) as api:
3193 res = api.SetUserContentVisibility(
3194 moderation_pb2.SetUserContentVisibilityReq(
3195 user_id=target.id,
3196 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3197 )
3198 )
3199 assert res.updated_count == 1
3201 with session_scope() as session:
3202 target_state = _get_moderation_state(session, ModerationObjectType.host_request, target_hr_id)
3203 other_state = _get_moderation_state(session, ModerationObjectType.host_request, other_hr_id)
3204 assert target_state.visibility == ModerationVisibility.hidden
3205 assert other_state.visibility == ModerationVisibility.shadowed
3208def test_SetUserContentVisibility_user_not_found(db):
3209 super_user, super_token = generate_user(is_superuser=True)
3211 with real_moderation_session(super_token) as api:
3212 with pytest.raises(grpc.RpcError) as e:
3213 api.SetUserContentVisibility(
3214 moderation_pb2.SetUserContentVisibilityReq(
3215 user_id=999999,
3216 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3217 )
3218 )
3219 assert e.value.code() == grpc.StatusCode.NOT_FOUND
3222def test_SetUserContentVisibility_from_visibility_filter(db):
3223 super_user, super_token = generate_user(is_superuser=True)
3224 surfer, surfer_token = generate_user()
3225 host, _ = generate_user()
3227 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3228 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3229 with requests_session(surfer_token) as api:
3230 hr_id = api.CreateHostRequest(
3231 requests_pb2.CreateHostRequestReq(
3232 host_user_id=host.id,
3233 from_date=today_plus_2,
3234 to_date=today_plus_3,
3235 text=valid_request_text(),
3236 )
3237 ).host_request_id
3239 with real_moderation_session(super_token) as api:
3240 api.SetUserContentVisibility(
3241 moderation_pb2.SetUserContentVisibilityReq(
3242 user_id=surfer.id,
3243 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3244 )
3245 )
3247 with real_moderation_session(super_token) as api:
3248 res = api.SetUserContentVisibility(
3249 moderation_pb2.SetUserContentVisibilityReq(
3250 user_id=surfer.id,
3251 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3252 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_SHADOWED],
3253 )
3254 )
3255 assert res.updated_count == 0
3257 with session_scope() as session:
3258 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3259 assert state.visibility == ModerationVisibility.visible
3261 with real_moderation_session(super_token) as api:
3262 res = api.SetUserContentVisibility(
3263 moderation_pb2.SetUserContentVisibilityReq(
3264 user_id=surfer.id,
3265 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3266 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_VISIBLE],
3267 )
3268 )
3269 assert res.updated_count == 1
3271 with session_scope() as session:
3272 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3273 assert state.visibility == ModerationVisibility.shadowed
3276def test_SetUserContentVisibility_from_visibility_multi(db):
3277 super_user, super_token = generate_user(is_superuser=True)
3278 surfer, surfer_token = generate_user()
3279 host1, _ = generate_user()
3280 host2, _ = generate_user()
3282 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3283 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3284 with requests_session(surfer_token) as api:
3285 hr1_id = api.CreateHostRequest(
3286 requests_pb2.CreateHostRequestReq(
3287 host_user_id=host1.id,
3288 from_date=today_plus_2,
3289 to_date=today_plus_3,
3290 text=valid_request_text(),
3291 )
3292 ).host_request_id
3293 hr2_id = api.CreateHostRequest(
3294 requests_pb2.CreateHostRequestReq(
3295 host_user_id=host2.id,
3296 from_date=today_plus_2,
3297 to_date=today_plus_3,
3298 text=valid_request_text(),
3299 )
3300 ).host_request_id
3302 with session_scope() as session:
3303 state1_id = _get_moderation_state(session, ModerationObjectType.host_request, hr1_id).id
3305 with real_moderation_session(super_token) as api:
3306 api.ModerateContent(
3307 moderation_pb2.ModerateContentReq(
3308 moderation_state_id=state1_id,
3309 action=moderation_pb2.MODERATION_ACTION_APPROVE,
3310 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3311 )
3312 )
3314 with real_moderation_session(super_token) as api:
3315 res = api.SetUserContentVisibility(
3316 moderation_pb2.SetUserContentVisibilityReq(
3317 user_id=surfer.id,
3318 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3319 from_visibility=[
3320 moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3321 moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3322 ],
3323 )
3324 )
3325 assert res.updated_count == 2
3327 with session_scope() as session:
3328 state1 = _get_moderation_state(session, ModerationObjectType.host_request, hr1_id)
3329 state2 = _get_moderation_state(session, ModerationObjectType.host_request, hr2_id)
3330 assert state1.visibility == ModerationVisibility.hidden
3331 assert state2.visibility == ModerationVisibility.hidden
3334def test_SetUserContentVisibility_from_visibility_empty_is_any(db):
3335 super_user, super_token = generate_user(is_superuser=True)
3336 surfer, surfer_token = generate_user()
3337 host, _ = generate_user()
3339 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3340 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3341 with requests_session(surfer_token) as api:
3342 hr_id = api.CreateHostRequest(
3343 requests_pb2.CreateHostRequestReq(
3344 host_user_id=host.id,
3345 from_date=today_plus_2,
3346 to_date=today_plus_3,
3347 text=valid_request_text(),
3348 )
3349 ).host_request_id
3351 with real_moderation_session(super_token) as api:
3352 res = api.SetUserContentVisibility(
3353 moderation_pb2.SetUserContentVisibilityReq(
3354 user_id=surfer.id,
3355 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3356 from_visibility=[],
3357 )
3358 )
3359 assert res.updated_count == 1
3361 with session_scope() as session:
3362 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3363 assert state.visibility == ModerationVisibility.hidden
3366def test_SetUserContentVisibility_from_visibility_unspecified_rejected(db):
3367 super_user, super_token = generate_user(is_superuser=True)
3368 target, _ = generate_user()
3370 with real_moderation_session(super_token) as api:
3371 with pytest.raises(grpc.RpcError) as e:
3372 api.SetUserContentVisibility(
3373 moderation_pb2.SetUserContentVisibilityReq(
3374 user_id=target.id,
3375 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3376 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED],
3377 )
3378 )
3379 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
3382def test_ListModerationStates_empty(db):
3383 super_user, super_token = generate_user(is_superuser=True)
3385 with real_moderation_session(super_token) as api:
3386 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq())
3387 assert len(res.moderation_states) == 0
3388 assert res.next_page_token == ""
3391def test_ListModerationStates_returns_states_chronologically(db):
3392 super_user, super_token = generate_user(is_superuser=True)
3393 surfer, surfer_token = generate_user()
3394 host, _ = generate_user()
3396 state1_id = create_test_host_request_with_moderation(surfer_token, host.id)
3397 state2_id = create_test_host_request_with_moderation(surfer_token, host.id)
3398 state3_id = create_test_host_request_with_moderation(surfer_token, host.id)
3400 with real_moderation_session(super_token) as api:
3401 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq())
3402 assert [s.moderation_state_id for s in res.moderation_states] == [state1_id, state2_id, state3_id]
3404 res_newest = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(newest_first=True))
3405 assert [s.moderation_state_id for s in res_newest.moderation_states] == [state3_id, state2_id, state1_id]
3408def test_ListModerationStates_filter_by_author(db):
3409 super_user, super_token = generate_user(is_superuser=True)
3410 surfer1, surfer1_token = generate_user()
3411 surfer2, surfer2_token = generate_user()
3412 host, _ = generate_user()
3414 state1_id = create_test_host_request_with_moderation(surfer1_token, host.id)
3415 state2_id = create_test_host_request_with_moderation(surfer2_token, host.id)
3416 state3_id = create_test_host_request_with_moderation(surfer1_token, host.id)
3418 with real_moderation_session(super_token) as api:
3419 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer1.id))
3420 assert {s.moderation_state_id for s in res.moderation_states} == {state1_id, state3_id}
3422 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer2.id))
3423 assert [s.moderation_state_id for s in res.moderation_states] == [state2_id]
3426def test_ListModerationStates_pagination(db):
3427 super_user, super_token = generate_user(is_superuser=True)
3428 surfer, surfer_token = generate_user()
3429 host, _ = generate_user()
3431 state_ids = [create_test_host_request_with_moderation(surfer_token, host.id) for _ in range(3)]
3433 with real_moderation_session(super_token) as api:
3434 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(page_size=2))
3435 assert [s.moderation_state_id for s in res.moderation_states] == state_ids[:2]
3436 assert res.next_page_token != ""
3438 res2 = api.ListModerationStates(
3439 moderation_pb2.ListModerationStatesReq(page_size=2, page_token=res.next_page_token)
3440 )
3441 assert [s.moderation_state_id for s in res2.moderation_states] == [state_ids[2]]
3442 assert res2.next_page_token == ""