Coverage for app/backend/src/tests/test_moderation.py: 100%
1436 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 00:33 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 00:33 +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 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 with session_scope() as session:
78 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
79 return hr.moderation_state_id
82# ============================================================================
83# Tests for moderation helper functions
84# ============================================================================
87def test_create_moderation(db):
88 """Test creating a moderation state with associated log entry"""
89 user, _ = generate_user()
91 with session_scope() as session:
92 # Create a moderation state
93 moderation_state = create_moderation(
94 session=session,
95 object_type=ModerationObjectType.host_request,
96 object_id=123,
97 creator_user_id=user.id,
98 )
100 assert moderation_state.object_type == ModerationObjectType.host_request
101 assert moderation_state.object_id == 123
102 assert moderation_state.visibility == ModerationVisibility.shadowed
104 # Check that log entry was created
105 log_entries = (
106 session.execute(select(ModerationLog).where(ModerationLog.moderation_state_id == moderation_state.id))
107 .scalars()
108 .all()
109 )
111 assert len(log_entries) == 1
112 assert log_entries[0].action == ModerationAction.create
113 assert log_entries[0].reason == "Object created."
114 assert log_entries[0].moderator_user_id == user.id
117def test_add_to_moderation_queue(db):
118 """Test adding content to moderation queue via API"""
119 super_user, super_token = generate_user(is_superuser=True)
120 user1, token1 = generate_user()
121 user2, _ = generate_user()
123 today_plus_2 = (today() + timedelta(days=2)).isoformat()
124 today_plus_3 = (today() + timedelta(days=3)).isoformat()
126 # Create a real host request (which automatically creates moderation state and adds to queue)
127 with requests_session(token1) as api:
128 host_request_id = api.CreateHostRequest(
129 requests_pb2.CreateHostRequestReq(
130 host_user_id=user2.id,
131 from_date=today_plus_2,
132 to_date=today_plus_3,
133 text=valid_request_text(),
134 )
135 ).host_request_id
137 # Get the moderation state ID
138 state_id = None
139 with session_scope() as session:
140 host_request = session.execute(
141 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
142 ).scalar_one()
143 state_id = host_request.moderation_state_id
145 # Add another item to moderation queue via API (the first one was created automatically)
146 with real_moderation_session(super_token) as api:
147 api.ModerateContent(
148 moderation_pb2.ModerateContentReq(
149 moderation_state_id=state_id,
150 action=moderation_pb2.MODERATION_ACTION_FLAG,
151 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
152 reason="Admin manually flagged for additional review",
153 )
154 )
156 with session_scope() as session:
157 flag = session.execute(
158 select(ModerationQueueItem)
159 .where(ModerationQueueItem.moderation_state_id == state_id)
160 .where(ModerationQueueItem.trigger == ModerationTrigger.user_flag)
161 ).scalar_one()
162 assert flag.reason == "Admin manually flagged for additional review"
163 assert flag.resolved_by_log_id is None
165 # The FLAG action is recorded in the log, pointing at the new queue item.
166 flag_log = session.execute(
167 select(ModerationLog)
168 .where(ModerationLog.moderation_state_id == state_id)
169 .where(ModerationLog.action == ModerationAction.flag)
170 ).scalar_one()
171 assert flag_log.queue_item_id == flag.id
174def test_moderate_content(db):
175 """Test moderating content via API"""
176 super_user, super_token = generate_user(is_superuser=True)
177 user, token = generate_user()
178 host, _ = generate_user()
180 today_plus_2 = (today() + timedelta(days=2)).isoformat()
181 today_plus_3 = (today() + timedelta(days=3)).isoformat()
183 # Create a real host request
184 state_id = None
185 with requests_session(token) as api:
186 hr_id = api.CreateHostRequest(
187 requests_pb2.CreateHostRequestReq(
188 host_user_id=host.id,
189 from_date=today_plus_2,
190 to_date=today_plus_3,
191 text=valid_request_text(),
192 )
193 ).host_request_id
195 with session_scope() as session:
196 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
197 state_id = hr.moderation_state_id
199 # Moderate the content via API
200 with real_moderation_session(super_token) as api:
201 res = api.ModerateContent(
202 moderation_pb2.ModerateContentReq(
203 moderation_state_id=state_id,
204 action=moderation_pb2.MODERATION_ACTION_APPROVE,
205 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
206 reason="Content looks good",
207 )
208 )
210 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
212 # Check that state was updated in database
213 with session_scope() as session:
214 updated_state = session.get_one(ModerationState, state_id)
215 assert updated_state.visibility == ModerationVisibility.visible
217 # Check that log entry was created
218 log_entries = (
219 session.execute(
220 select(ModerationLog)
221 .where(ModerationLog.moderation_state_id == state_id)
222 .order_by(ModerationLog.time.desc(), ModerationLog.id.desc())
223 )
224 .scalars()
225 .all()
226 )
228 assert len(log_entries) == 2 # CREATE + APPROVE
229 assert log_entries[0].action == ModerationAction.approve
230 assert log_entries[0].moderator_user_id == super_user.id
231 assert log_entries[0].reason == "Content looks good"
234def test_resolve_queue_item(db):
235 """Test resolving a moderation queue item via ModerateContent API"""
236 user1, token1 = generate_user()
237 user2, _ = generate_user()
238 moderator, moderator_token = generate_user(is_superuser=True)
240 today_plus_2 = (today() + timedelta(days=2)).isoformat()
241 today_plus_3 = (today() + timedelta(days=3)).isoformat()
243 # Create a host request using the API (which automatically creates moderation state)
244 with requests_session(token1) as api:
245 host_request_id = api.CreateHostRequest(
246 requests_pb2.CreateHostRequestReq(
247 host_user_id=user2.id,
248 from_date=today_plus_2,
249 to_date=today_plus_3,
250 text=valid_request_text(),
251 )
252 ).host_request_id
254 state_id = None
255 with session_scope() as session:
256 # Get the host request and its moderation state
257 host_request = session.execute(
258 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
259 ).scalar_one()
260 state_id = host_request.moderation_state_id
262 # The moderation state should already exist and be in the queue
263 queue_item = session.execute(
264 select(ModerationQueueItem)
265 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
266 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
267 ).scalar_one()
269 assert queue_item.resolved_by_log_id is None
271 # Approve content with clear_flags, which resolves the open queue item(s)
272 with real_moderation_session(moderator_token) as api:
273 api.ModerateContent(
274 moderation_pb2.ModerateContentReq(
275 moderation_state_id=state_id,
276 action=moderation_pb2.MODERATION_ACTION_APPROVE,
277 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
278 reason="Approved after review",
279 clear_flags=True,
280 )
281 )
283 # Check that queue item was resolved
284 with session_scope() as session:
285 queue_item = session.execute(
286 select(ModerationQueueItem)
287 .where(ModerationQueueItem.moderation_state_id == state_id)
288 .where(ModerationQueueItem.resolved_by_log_id.is_not(None))
289 ).scalar_one()
290 assert queue_item.resolved_by_log_id is not None
293def test_approve_content_via_api(db):
294 """Test approving content via ModerateContent API"""
295 user1, token1 = generate_user()
296 user2, _ = generate_user()
297 moderator, moderator_token = generate_user(is_superuser=True)
299 today_plus_2 = (today() + timedelta(days=2)).isoformat()
300 today_plus_3 = (today() + timedelta(days=3)).isoformat()
302 # Create a host request using the API (which automatically creates moderation state)
303 with requests_session(token1) as api:
304 host_request_id = api.CreateHostRequest(
305 requests_pb2.CreateHostRequestReq(
306 host_user_id=user2.id,
307 from_date=today_plus_2,
308 to_date=today_plus_3,
309 text=valid_request_text(),
310 )
311 ).host_request_id
313 state_id = None
314 with session_scope() as session:
315 # Get the host request and its moderation state
316 host_request = session.execute(
317 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
318 ).scalar_one()
319 state_id = host_request.moderation_state_id
321 # Approve via API
322 with real_moderation_session(moderator_token) as api:
323 api.ModerateContent(
324 moderation_pb2.ModerateContentReq(
325 moderation_state_id=state_id,
326 action=moderation_pb2.MODERATION_ACTION_APPROVE,
327 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
328 reason="Quick approval",
329 )
330 )
332 # Check that state was updated to VISIBLE
333 with session_scope() as session:
334 updated_state = session.get_one(ModerationState, state_id)
335 assert updated_state.visibility == ModerationVisibility.visible
337 # Check log entry
338 log_entry = session.execute(
339 select(ModerationLog)
340 .where(ModerationLog.moderation_state_id == state_id)
341 .where(ModerationLog.action == ModerationAction.approve)
342 ).scalar_one()
344 assert log_entry.moderator_user_id == moderator.id
345 assert log_entry.reason == "Quick approval"
348# ============================================================================
349# Tests for host request moderation integration
350# ============================================================================
353def test_create_host_request_creates_moderation_state(db):
354 """Test that creating a host request automatically creates a moderation state"""
355 user1, token1 = generate_user()
356 user2, token2 = generate_user()
358 today_plus_2 = (today() + timedelta(days=2)).isoformat()
359 today_plus_3 = (today() + timedelta(days=3)).isoformat()
361 with requests_session(token1) as api:
362 host_request_id = api.CreateHostRequest(
363 requests_pb2.CreateHostRequestReq(
364 host_user_id=user2.id,
365 from_date=today_plus_2,
366 to_date=today_plus_3,
367 text=valid_request_text(),
368 )
369 ).host_request_id
371 with session_scope() as session:
372 # Check that host request has a moderation state
373 host_request = session.execute(
374 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
375 ).scalar_one()
377 # Check moderation state properties
378 moderation_state = session.execute(
379 select(ModerationState).where(ModerationState.id == host_request.moderation_state_id)
380 ).scalar_one()
382 assert moderation_state.object_type == ModerationObjectType.host_request
383 assert moderation_state.object_id == host_request_id
384 assert moderation_state.visibility == ModerationVisibility.shadowed
386 # Check that it was added to moderation queue
387 queue_items = (
388 session.execute(
389 select(ModerationQueueItem)
390 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
391 .where(ModerationQueueItem.resolved_by_log_id == None)
392 )
393 .scalars()
394 .all()
395 )
397 assert len(queue_items) == 1
398 assert queue_items[0].trigger == ModerationTrigger.initial_review
399 # item_author_user_id is no longer stored in the model, it's dynamically retrieved
402def test_host_request_no_notification_before_approval(db, push_collector: PushCollector):
403 """Test that host requests don't send notifications until approved"""
404 user1, token1 = generate_user()
405 user2, token2 = generate_user()
407 today_plus_2 = (today() + timedelta(days=2)).isoformat()
408 today_plus_3 = (today() + timedelta(days=3)).isoformat()
410 with requests_session(token1) as api:
411 host_request_id = api.CreateHostRequest(
412 requests_pb2.CreateHostRequestReq(
413 host_user_id=user2.id,
414 from_date=today_plus_2,
415 to_date=today_plus_3,
416 text=valid_request_text(),
417 )
418 ).host_request_id
420 # Process all jobs (including the notification job)
421 process_jobs()
423 # No push notification should be sent yet (host requests are shadowed initially)
424 assert push_collector.count_for_user(user2.id) == 0
427def test_shadowed_notification_not_in_list_notifications(db):
428 """Test that notifications for shadowed content don't appear in ListNotifications API"""
429 user1, token1 = generate_user()
430 user2, token2 = generate_user()
432 today_plus_2 = (today() + timedelta(days=2)).isoformat()
433 today_plus_3 = (today() + timedelta(days=3)).isoformat()
435 # Create a host request (which creates a shadowed notification for the host)
436 with requests_session(token1) as api:
437 host_request_id = api.CreateHostRequest(
438 requests_pb2.CreateHostRequestReq(
439 host_user_id=user2.id,
440 from_date=today_plus_2,
441 to_date=today_plus_3,
442 text=valid_request_text(),
443 )
444 ).host_request_id
446 # Host (recipient) should NOT see the notification in ListNotifications - it's for shadowed content
447 with notifications_session(token2) as api:
448 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
449 # Should be empty - the host request is still shadowed
450 assert len(res.notifications) == 0
453def test_notification_visible_after_approval(db):
454 """Test that notifications appear in ListNotifications after content is approved"""
455 user1, token1 = generate_user()
456 user2, token2 = generate_user()
457 mod, mod_token = generate_user(is_superuser=True)
459 today_plus_2 = (today() + timedelta(days=2)).isoformat()
460 today_plus_3 = (today() + timedelta(days=3)).isoformat()
462 # Create a host request (which creates a shadowed notification for the host)
463 with requests_session(token1) as api:
464 host_request_id = api.CreateHostRequest(
465 requests_pb2.CreateHostRequestReq(
466 host_user_id=user2.id,
467 from_date=today_plus_2,
468 to_date=today_plus_3,
469 text=valid_request_text(),
470 )
471 ).host_request_id
473 # Host (recipient) should NOT see the notification initially
474 with notifications_session(token2) as api:
475 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
476 assert len(res.notifications) == 0
478 # Get the moderation state ID and approve
479 with session_scope() as session:
480 host_request = session.execute(
481 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
482 ).scalar_one()
483 state_id = host_request.moderation_state_id
485 with real_moderation_session(mod_token) as api:
486 api.ModerateContent(
487 moderation_pb2.ModerateContentReq(
488 moderation_state_id=state_id,
489 action=moderation_pb2.MODERATION_ACTION_APPROVE,
490 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
491 reason="Looks good",
492 )
493 )
495 # Now host SHOULD see the notification
496 with notifications_session(token2) as api:
497 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
498 assert len(res.notifications) == 1
499 assert res.notifications[0].topic == "host_request"
500 assert res.notifications[0].action == "create"
503def test_shadowed_host_request_visible_to_author_only(db):
504 """Test that SHADOWED host requests are visible only to the author (surfer), not the recipient (host)"""
505 user1, token1 = generate_user()
506 user2, token2 = generate_user()
508 today_plus_2 = (today() + timedelta(days=2)).isoformat()
509 today_plus_3 = (today() + timedelta(days=3)).isoformat()
511 with requests_session(token1) as api:
512 host_request_id = api.CreateHostRequest(
513 requests_pb2.CreateHostRequestReq(
514 host_user_id=user2.id,
515 from_date=today_plus_2,
516 to_date=today_plus_3,
517 text=valid_request_text(),
518 )
519 ).host_request_id
521 # Surfer (author) can see it with GetHostRequest
522 with requests_session(token1) as api:
523 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
524 assert res.host_request_id == host_request_id
525 assert res.latest_message.text.text == valid_request_text()
527 # Host (recipient) CANNOT see it with GetHostRequest - it's shadowed
528 with requests_session(token2) as api:
529 with pytest.raises(grpc.RpcError) as e:
530 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
531 assert e.value.code() == grpc.StatusCode.NOT_FOUND
534def test_unlisted_host_request_not_in_lists(db):
535 """Test that SHADOWED host requests are visible to author but not to recipient"""
536 user1, token1 = generate_user()
537 user2, token2 = generate_user()
539 today_plus_2 = (today() + timedelta(days=2)).isoformat()
540 today_plus_3 = (today() + timedelta(days=3)).isoformat()
542 with requests_session(token1) as api:
543 host_request_id = api.CreateHostRequest(
544 requests_pb2.CreateHostRequestReq(
545 host_user_id=user2.id,
546 from_date=today_plus_2,
547 to_date=today_plus_3,
548 text=valid_request_text(),
549 )
550 ).host_request_id
552 # Surfer (author) should see it in their sent list even though it's SHADOWED
553 with requests_session(token1) as api:
554 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
555 assert len(res.host_requests) == 1
557 # Host should NOT see it in their received list (still SHADOWED from them)
558 with requests_session(token2) as api:
559 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
560 assert len(res.host_requests) == 0
563def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector):
564 """Test that approved host requests appear in lists and send notifications"""
565 user1, token1 = generate_user()
566 user2, token2 = generate_user()
567 mod, mod_token = generate_user(is_superuser=True)
569 today_plus_2 = (today() + timedelta(days=2)).isoformat()
570 today_plus_3 = (today() + timedelta(days=3)).isoformat()
572 with requests_session(token1) as api:
573 host_request_id = api.CreateHostRequest(
574 requests_pb2.CreateHostRequestReq(
575 host_user_id=user2.id,
576 from_date=today_plus_2,
577 to_date=today_plus_3,
578 text=valid_request_text(),
579 )
580 ).host_request_id
582 # Process the initial notification job - should be deferred (no notification sent)
583 process_jobs()
584 assert push_collector.count_for_user(user2.id) == 0
586 # Get the moderation state ID
587 state_id = None
588 with session_scope() as session:
589 host_request = session.execute(
590 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
591 ).scalar_one()
592 state_id = host_request.moderation_state_id
594 # Approve the host request via API
595 with real_moderation_session(mod_token) as api:
596 api.ModerateContent(
597 moderation_pb2.ModerateContentReq(
598 moderation_state_id=state_id,
599 action=moderation_pb2.MODERATION_ACTION_APPROVE,
600 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
601 reason="Looks good",
602 )
603 )
605 # Process the re-queued notification job - should now send notification
606 process_jobs()
608 # Now surfer SHOULD see it in their sent list
609 with requests_session(token1) as api:
610 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
611 assert len(res.host_requests) == 1
612 assert res.host_requests[0].host_request_id == host_request_id
614 # Host SHOULD see it in their received list
615 with requests_session(token2) as api:
616 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
617 assert len(res.host_requests) == 1
618 assert res.host_requests[0].host_request_id == host_request_id
620 # After approval, the host should have received a push notification
621 assert push_collector.pop_for_user(user2.id, last=True).topic_action == "host_request:create"
624def test_hidden_host_request_invisible_to_all(db):
625 """Test that HIDDEN host requests are invisible to everyone except moderators"""
626 user1, token1 = generate_user()
627 user2, token2 = generate_user()
628 user3, token3 = generate_user() # Third party
629 moderator, moderator_token = generate_user(is_superuser=True)
631 today_plus_2 = (today() + timedelta(days=2)).isoformat()
632 today_plus_3 = (today() + timedelta(days=3)).isoformat()
634 with requests_session(token1) as api:
635 host_request_id = api.CreateHostRequest(
636 requests_pb2.CreateHostRequestReq(
637 host_user_id=user2.id,
638 from_date=today_plus_2,
639 to_date=today_plus_3,
640 text=valid_request_text(),
641 )
642 ).host_request_id
644 # Get the moderation state ID
645 state_id = None
646 with session_scope() as session:
647 host_request = session.execute(
648 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
649 ).scalar_one()
650 state_id = host_request.moderation_state_id
652 # Hide the host request via API (e.g., spam/abuse)
653 with real_moderation_session(moderator_token) as api:
654 api.ModerateContent(
655 moderation_pb2.ModerateContentReq(
656 moderation_state_id=state_id,
657 action=moderation_pb2.MODERATION_ACTION_HIDE,
658 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
659 reason="Spam content",
660 )
661 )
663 # Surfer can't see it with GetHostRequest
664 with requests_session(token1) as api:
665 with pytest.raises(grpc.RpcError) as e:
666 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
667 assert e.value.code() == grpc.StatusCode.NOT_FOUND
669 # Host can't see it with GetHostRequest
670 with requests_session(token2) as api:
671 with pytest.raises(grpc.RpcError) as e:
672 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
673 assert e.value.code() == grpc.StatusCode.NOT_FOUND
675 # Third party definitely can't see it
676 with requests_session(token3) as api:
677 with pytest.raises(grpc.RpcError) as e:
678 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
679 assert e.value.code() == grpc.StatusCode.NOT_FOUND
681 # Not in any lists
682 with requests_session(token1) as api:
683 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
684 assert len(res.host_requests) == 0
686 with requests_session(token2) as api:
687 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
688 assert len(res.host_requests) == 0
691def test_multiple_host_requests_listing_visibility(db):
692 """Test that ListHostRequests correctly filters based on moderation state"""
693 user1, token1 = generate_user()
694 user2, token2 = generate_user()
695 moderator, moderator_token = generate_user(is_superuser=True)
697 today_plus_2 = (today() + timedelta(days=2)).isoformat()
698 today_plus_3 = (today() + timedelta(days=3)).isoformat()
700 # Create 3 host requests
701 host_request_ids = []
702 state_ids = []
703 with requests_session(token1) as api:
704 for i in range(3):
705 hr_id = api.CreateHostRequest(
706 requests_pb2.CreateHostRequestReq(
707 host_user_id=user2.id,
708 from_date=today_plus_2,
709 to_date=today_plus_3,
710 text=valid_request_text(f"Test request {i + 1}"),
711 )
712 ).host_request_id
713 host_request_ids.append(hr_id)
715 # Get state IDs
716 with session_scope() as session:
717 for hr_id in host_request_ids:
718 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
719 state_ids.append(host_request.moderation_state_id)
721 # Approve the first one via API
722 with real_moderation_session(moderator_token) as api:
723 api.ModerateContent(
724 moderation_pb2.ModerateContentReq(
725 moderation_state_id=state_ids[0],
726 action=moderation_pb2.MODERATION_ACTION_APPROVE,
727 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
728 reason="Approved",
729 )
730 )
732 # Hide the third one via API
733 with real_moderation_session(moderator_token) as api:
734 api.ModerateContent(
735 moderation_pb2.ModerateContentReq(
736 moderation_state_id=state_ids[2],
737 action=moderation_pb2.MODERATION_ACTION_HIDE,
738 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
739 reason="Spam",
740 )
741 )
743 # Surfer should see the approved one and the shadowed one (author can see their SHADOWED content)
744 with requests_session(token1) as api:
745 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
746 assert len(res.host_requests) == 2
747 visible_ids = {hr.host_request_id for hr in res.host_requests}
748 assert visible_ids == {host_request_ids[0], host_request_ids[1]}
750 # Host should see only the approved one in received list
751 with requests_session(token2) as api:
752 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
753 assert len(res.host_requests) == 1
754 assert res.host_requests[0].host_request_id == host_request_ids[0]
757def test_moderation_log_tracking(db):
758 """Test that moderation actions are properly logged via API"""
759 user, user_token = generate_user()
760 host, _ = generate_user()
761 moderator1, moderator1_token = generate_user(is_superuser=True)
762 moderator2, moderator2_token = generate_user(is_superuser=True)
764 # Create a real host request
765 state_id = create_test_host_request_with_moderation(user_token, host.id)
767 # Perform several moderation actions via API
768 with real_moderation_session(moderator1_token) as api:
769 api.ModerateContent(
770 moderation_pb2.ModerateContentReq(
771 moderation_state_id=state_id,
772 action=moderation_pb2.MODERATION_ACTION_APPROVE,
773 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
774 reason="Looks good initially",
775 )
776 )
778 with real_moderation_session(moderator2_token) as api:
779 api.ModerateContent(
780 moderation_pb2.ModerateContentReq(
781 moderation_state_id=state_id,
782 action=moderation_pb2.MODERATION_ACTION_FLAG,
783 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
784 reason="Wait, this needs another look",
785 )
786 )
787 # Shadow it back
788 api.ModerateContent(
789 moderation_pb2.ModerateContentReq(
790 moderation_state_id=state_id,
791 action=moderation_pb2.MODERATION_ACTION_HIDE,
792 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
793 reason="Wait, this needs another look",
794 )
795 )
797 with real_moderation_session(moderator1_token) as api:
798 api.ModerateContent(
799 moderation_pb2.ModerateContentReq(
800 moderation_state_id=state_id,
801 action=moderation_pb2.MODERATION_ACTION_HIDE,
802 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
803 reason="Actually it's spam",
804 )
805 )
807 # Check all log entries
808 with session_scope() as session:
809 log_entries = (
810 session.execute(
811 select(ModerationLog)
812 .where(ModerationLog.moderation_state_id == state_id)
813 .order_by(ModerationLog.time.asc())
814 )
815 .scalars()
816 .all()
817 )
819 # CREATE + APPROVE + HIDE + HIDE (shadowing back counts as HIDE action)
820 assert len(log_entries) >= 3
822 assert log_entries[0].action == ModerationAction.create
823 assert log_entries[0].moderator_user_id == user.id
824 assert log_entries[0].reason == "Object created."
826 assert log_entries[1].action == ModerationAction.approve
827 assert log_entries[1].moderator_user_id == moderator1.id
828 assert log_entries[1].reason == "Looks good initially"
830 # The last action should be hiding
831 assert log_entries[-1].action == ModerationAction.hide
832 assert log_entries[-1].moderator_user_id == moderator1.id
833 assert log_entries[-1].reason == "Actually it's spam"
836def test_moderation_queue_workflow(db):
837 """Test the full moderation queue workflow via API"""
838 user1, token1 = generate_user()
839 user2, _ = generate_user()
840 moderator, moderator_token = generate_user(is_superuser=True)
842 today_plus_2 = (today() + timedelta(days=2)).isoformat()
843 today_plus_3 = (today() + timedelta(days=3)).isoformat()
845 # Create a host request using the API (which automatically creates moderation state and adds to queue)
846 with requests_session(token1) as api:
847 host_request_id = api.CreateHostRequest(
848 requests_pb2.CreateHostRequestReq(
849 host_user_id=user2.id,
850 from_date=today_plus_2,
851 to_date=today_plus_3,
852 text=valid_request_text(),
853 )
854 ).host_request_id
856 state_id = None
857 queue_item_id = None
858 with session_scope() as session:
859 # Get the host request and its moderation state
860 host_request = session.execute(
861 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
862 ).scalar_one()
863 state_id = host_request.moderation_state_id
865 # The queue item should already exist (created automatically)
866 queue_item = session.execute(
867 select(ModerationQueueItem)
868 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
869 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
870 ).scalar_one()
871 queue_item_id = queue_item.id
873 # Verify it's in the queue
874 unresolved_items = (
875 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
876 .scalars()
877 .all()
878 )
880 assert len(unresolved_items) >= 1
881 assert queue_item.id in [item.id for item in unresolved_items]
883 # Moderator reviews and approves via API, clearing the open queue item
884 with real_moderation_session(moderator_token) as api:
885 api.ModerateContent(
886 moderation_pb2.ModerateContentReq(
887 moderation_state_id=state_id,
888 action=moderation_pb2.MODERATION_ACTION_APPROVE,
889 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
890 reason="Content approved",
891 clear_flags=True,
892 )
893 )
895 # Verify queue item was resolved
896 with session_scope() as session:
897 # Verify it's no longer in unresolved queue
898 unresolved_items = (
899 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
900 .scalars()
901 .all()
902 )
904 assert queue_item_id not in [item.id for item in unresolved_items]
906 # Verify the queue item was linked to a log entry
907 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
908 assert queue_item.resolved_by_log_id is not None
911# ============================================================================
912# Moderation API Tests (testing the gRPC servicer)
913# ============================================================================
916def test_GetModerationQueue_empty(db):
917 """Test getting an empty moderation queue"""
918 super_user, super_token = generate_user(is_superuser=True)
920 with real_moderation_session(super_token) as api:
921 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
922 assert len(res.queue_items) == 0
923 assert res.next_page_token == ""
926def test_GetModerationQueue_with_items(db):
927 """Test getting moderation queue with items via API"""
928 super_user, super_token = generate_user(is_superuser=True)
929 normal_user, user_token = generate_user()
930 host, _ = generate_user()
932 # Create some host requests (which automatically adds them to moderation queue)
933 state1_id = create_test_host_request_with_moderation(user_token, host.id)
934 state2_id = create_test_host_request_with_moderation(user_token, host.id)
936 with real_moderation_session(super_token) as api:
937 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
938 assert len(res.queue_items) == 2
939 assert res.queue_items[0].is_resolved == False
940 assert res.queue_items[1].is_resolved == False
943def test_GetModerationQueue_filter_by_trigger(db):
944 """Test filtering moderation queue by trigger type via API"""
945 super_user, super_token = generate_user(is_superuser=True)
946 normal_user, user_token = generate_user()
947 host, _ = generate_user()
949 # Create host requests (which automatically adds them to moderation queue with INITIAL_REVIEW)
950 state1_id = create_test_host_request_with_moderation(user_token, host.id)
951 state2_id = create_test_host_request_with_moderation(user_token, host.id)
953 # Add USER_FLAG trigger to second item via API
954 with real_moderation_session(super_token) as api:
955 api.ModerateContent(
956 moderation_pb2.ModerateContentReq(
957 moderation_state_id=state2_id,
958 action=moderation_pb2.MODERATION_ACTION_FLAG,
959 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
960 reason="Reported by user",
961 )
962 )
964 # Filter by INITIAL_REVIEW (should get first item and maybe both depending on how queue works)
965 with real_moderation_session(super_token) as api:
966 res = api.GetModerationQueue(
967 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW])
968 )
969 assert len(res.queue_items) == 2 # Both have INITIAL_REVIEW triggers
970 assert all(item.trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW for item in res.queue_items)
972 # Filter by USER_FLAG (should get second item only)
973 with real_moderation_session(super_token) as api:
974 res = api.GetModerationQueue(
975 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_USER_FLAG])
976 )
977 assert len(res.queue_items) == 1
978 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG
981def test_GetModerationQueue_filter_created_before(db):
982 """Test filtering moderation queue by created_before timestamp"""
983 super_user, super_token = generate_user(is_superuser=True)
984 normal_user, user_token = generate_user()
985 host, _ = generate_user()
987 # Create host requests
988 state1_id = create_test_host_request_with_moderation(user_token, host.id)
989 state2_id = create_test_host_request_with_moderation(user_token, host.id)
991 # Backdate the first queue item
992 with session_scope() as session:
993 queue_item1 = session.execute(
994 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
995 ).scalar_one()
996 # Set it to 2 hours ago
997 queue_item1.time_created = now() - timedelta(hours=2)
999 # The second item remains at current time
1001 # Filter to items created before 1 hour ago (should only get the first item)
1002 cutoff_time = now() - timedelta(hours=1)
1003 with real_moderation_session(super_token) as api:
1004 res = api.GetModerationQueue(
1005 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(cutoff_time))
1006 )
1007 assert len(res.queue_items) == 1
1008 assert res.queue_items[0].moderation_state_id == state1_id
1010 # Filter to items created before now (should get both)
1011 with real_moderation_session(super_token) as api:
1012 res = api.GetModerationQueue(
1013 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(now() + timedelta(seconds=10)))
1014 )
1015 assert len(res.queue_items) == 2
1017 # Filter to items created before 3 hours ago (should get none)
1018 old_cutoff = now() - timedelta(hours=3)
1019 with real_moderation_session(super_token) as api:
1020 res = api.GetModerationQueue(
1021 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(old_cutoff))
1022 )
1023 assert len(res.queue_items) == 0
1026def test_GetModerationQueue_filter_created_after(db):
1027 """Test filtering moderation queue by created_after timestamp"""
1028 super_user, super_token = generate_user(is_superuser=True)
1029 normal_user, user_token = generate_user()
1030 host, _ = generate_user()
1032 # Create host requests
1033 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1034 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1036 # Backdate the first queue item to 2 hours ago
1037 with session_scope() as session:
1038 queue_item1 = session.execute(
1039 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1040 ).scalar_one()
1041 queue_item1.time_created = now() - timedelta(hours=2)
1043 # The second item remains at current time
1045 # Filter to items created after 1 hour ago (should only get the second item)
1046 cutoff_time = now() - timedelta(hours=1)
1047 with real_moderation_session(super_token) as api:
1048 res = api.GetModerationQueue(
1049 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(cutoff_time))
1050 )
1051 assert len(res.queue_items) == 1
1052 assert res.queue_items[0].moderation_state_id == state2_id
1054 # Filter to items created after 3 hours ago (should get both)
1055 old_cutoff = now() - timedelta(hours=3)
1056 with real_moderation_session(super_token) as api:
1057 res = api.GetModerationQueue(
1058 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(old_cutoff))
1059 )
1060 assert len(res.queue_items) == 2
1062 # Filter to items created after now (should get none)
1063 with real_moderation_session(super_token) as api:
1064 res = api.GetModerationQueue(
1065 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(now() + timedelta(seconds=10)))
1066 )
1067 assert len(res.queue_items) == 0
1070def test_GetModerationQueue_filter_created_before_and_after(db):
1071 """Test filtering moderation queue by both created_before and created_after timestamps"""
1072 super_user, super_token = generate_user(is_superuser=True)
1073 normal_user, user_token = generate_user()
1074 host, _ = generate_user()
1076 # Create 3 host requests
1077 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1078 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1079 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1081 # Set different times: state1 = 3 hours ago, state2 = 1.5 hours ago, state3 = now
1082 with session_scope() as session:
1083 queue_item1 = session.execute(
1084 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1085 ).scalar_one()
1086 queue_item1.time_created = now() - timedelta(hours=3)
1088 queue_item2 = session.execute(
1089 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1090 ).scalar_one()
1091 queue_item2.time_created = now() - timedelta(hours=1, minutes=30)
1093 # Filter to items between 2 hours ago and 1 hour ago (should only get state2)
1094 after_cutoff = now() - timedelta(hours=2)
1095 before_cutoff = now() - timedelta(hours=1)
1096 with real_moderation_session(super_token) as api:
1097 res = api.GetModerationQueue(
1098 moderation_pb2.GetModerationQueueReq(
1099 created_after=Timestamp_from_datetime(after_cutoff),
1100 created_before=Timestamp_from_datetime(before_cutoff),
1101 )
1102 )
1103 assert len(res.queue_items) == 1
1104 assert res.queue_items[0].moderation_state_id == state2_id
1106 # Filter to items between 4 hours ago and 2.5 hours ago (should only get state1)
1107 after_cutoff = now() - timedelta(hours=4)
1108 before_cutoff = now() - timedelta(hours=2, minutes=30)
1109 with real_moderation_session(super_token) as api:
1110 res = api.GetModerationQueue(
1111 moderation_pb2.GetModerationQueueReq(
1112 created_after=Timestamp_from_datetime(after_cutoff),
1113 created_before=Timestamp_from_datetime(before_cutoff),
1114 )
1115 )
1116 assert len(res.queue_items) == 1
1117 assert res.queue_items[0].moderation_state_id == state1_id
1120def test_GetModerationQueue_filter_unresolved(db):
1121 """Test filtering moderation queue for unresolved items only via API"""
1122 super_user, super_token = generate_user(is_superuser=True)
1123 normal_user, user_token = generate_user()
1124 host, _ = generate_user()
1126 # Create 2 host requests
1127 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1128 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1130 # Resolve the first one via API (approve with clear_flags resolves the queue item)
1131 with real_moderation_session(super_token) as api:
1132 api.ModerateContent(
1133 moderation_pb2.ModerateContentReq(
1134 moderation_state_id=state1_id,
1135 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1136 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1137 reason="Approved",
1138 clear_flags=True,
1139 )
1140 )
1142 # Get all items
1143 with real_moderation_session(super_token) as api:
1144 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1145 assert len(res.queue_items) == 2
1147 # Get only unresolved items
1148 with real_moderation_session(super_token) as api:
1149 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1150 assert len(res.queue_items) == 1
1151 assert res.queue_items[0].is_resolved == False
1154def test_GetModerationQueue_filter_by_author(db):
1155 """Test filtering moderation queue by item_author_user_id"""
1156 super_user, super_token = generate_user(is_superuser=True)
1157 user1, token1 = generate_user()
1158 user2, token2 = generate_user()
1159 host_user, _ = generate_user()
1161 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1162 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1164 # Create 2 host requests by user1
1165 with requests_session(token1) as api:
1166 hr1_id = api.CreateHostRequest(
1167 requests_pb2.CreateHostRequestReq(
1168 host_user_id=host_user.id,
1169 from_date=today_plus_2,
1170 to_date=today_plus_3,
1171 text=valid_request_text(),
1172 )
1173 ).host_request_id
1175 hr2_id = api.CreateHostRequest(
1176 requests_pb2.CreateHostRequestReq(
1177 host_user_id=host_user.id,
1178 from_date=today_plus_2,
1179 to_date=today_plus_3,
1180 text=valid_request_text(),
1181 )
1182 ).host_request_id
1184 # Create 1 host request by user2
1185 with requests_session(token2) as api:
1186 hr3_id = api.CreateHostRequest(
1187 requests_pb2.CreateHostRequestReq(
1188 host_user_id=host_user.id,
1189 from_date=today_plus_2,
1190 to_date=today_plus_3,
1191 text=valid_request_text(),
1192 )
1193 ).host_request_id
1195 # Get moderation state IDs
1196 state1_id, state2_id, state3_id = None, None, None
1197 with session_scope() as session:
1198 hr1 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr1_id)).scalar_one()
1199 hr2 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr2_id)).scalar_one()
1200 hr3 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr3_id)).scalar_one()
1201 state1_id = hr1.moderation_state_id
1202 state2_id = hr2.moderation_state_id
1203 state3_id = hr3.moderation_state_id
1205 # Get all items (should be 3)
1206 with real_moderation_session(super_token) as api:
1207 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1208 assert len(res.queue_items) == 3
1210 # Filter by user1 (should get 2)
1211 with real_moderation_session(super_token) as api:
1212 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user1.id))
1213 assert len(res.queue_items) == 2
1214 assert all(item.moderation_state.author.user_id == user1.id for item in res.queue_items)
1216 # Filter by user2 (should get 1)
1217 with real_moderation_session(super_token) as api:
1218 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user2.id))
1219 assert len(res.queue_items) == 1
1220 assert res.queue_items[0].moderation_state.author.user_id == user2.id
1221 assert res.queue_items[0].moderation_state_id == state3_id
1223 # Filter by non-existent user (should get 0)
1224 with real_moderation_session(super_token) as api:
1225 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=999999))
1226 assert len(res.queue_items) == 0
1229def test_GetModerationQueue_ordering(db):
1230 """Test ordering moderation queue by oldest/newest first"""
1231 super_user, super_token = generate_user(is_superuser=True)
1232 normal_user, user_token = generate_user()
1233 host, _ = generate_user()
1235 # Create 3 host requests
1236 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1237 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1238 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1240 # Set different times: state1 = 3 hours ago, state2 = 2 hours ago, state3 = 1 hour ago
1241 with session_scope() as session:
1242 queue_item1 = session.execute(
1243 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1244 ).scalar_one()
1245 queue_item1.time_created = now() - timedelta(hours=3)
1247 queue_item2 = session.execute(
1248 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1249 ).scalar_one()
1250 queue_item2.time_created = now() - timedelta(hours=2)
1252 queue_item3 = session.execute(
1253 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state3_id)
1254 ).scalar_one()
1255 queue_item3.time_created = now() - timedelta(hours=1)
1257 # Default order (oldest first)
1258 with real_moderation_session(super_token) as api:
1259 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1260 assert len(res.queue_items) == 3
1261 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1262 assert res.queue_items[1].moderation_state_id == state2_id
1263 assert res.queue_items[2].moderation_state_id == state3_id # newest
1265 # Explicit oldest first
1266 with real_moderation_session(super_token) as api:
1267 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=False))
1268 assert len(res.queue_items) == 3
1269 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1270 assert res.queue_items[2].moderation_state_id == state3_id # newest
1272 # Newest first
1273 with real_moderation_session(super_token) as api:
1274 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=True))
1275 assert len(res.queue_items) == 3
1276 assert res.queue_items[0].moderation_state_id == state3_id # newest
1277 assert res.queue_items[1].moderation_state_id == state2_id
1278 assert res.queue_items[2].moderation_state_id == state1_id # oldest
1281def test_GetModerationQueue_pagination_newest_first(db):
1282 """Test pagination with newest_first=True returns different items on each page"""
1283 super_user, super_token = generate_user(is_superuser=True)
1284 normal_user, normal_token = generate_user()
1285 host_user, _ = generate_user()
1287 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1288 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1290 # Create 5 host requests
1291 hr_ids = []
1292 with requests_session(normal_token) as api:
1293 for i in range(5):
1294 hr_id = api.CreateHostRequest(
1295 requests_pb2.CreateHostRequestReq(
1296 host_user_id=host_user.id,
1297 from_date=today_plus_2,
1298 to_date=today_plus_3,
1299 text=valid_request_text(),
1300 )
1301 ).host_request_id
1302 hr_ids.append(hr_id)
1304 # Get moderation state IDs
1305 state_ids = []
1306 with session_scope() as session:
1307 for hr_id in hr_ids:
1308 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
1309 state_ids.append(hr.moderation_state_id)
1311 # Set different times so ordering is deterministic
1312 with session_scope() as session:
1313 for i, state_id in enumerate(state_ids):
1314 queue_item = session.execute(
1315 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
1316 ).scalar_one()
1317 queue_item.time_created = now() - timedelta(hours=5 - i) # oldest first in list
1319 # Get first page (2 items) with newest_first=True, filtered to our user's items
1320 with real_moderation_session(super_token) as api:
1321 res1 = api.GetModerationQueue(
1322 moderation_pb2.GetModerationQueueReq(page_size=2, newest_first=True, item_author_user_id=normal_user.id)
1323 )
1324 assert len(res1.queue_items) == 2
1325 # Should get newest items: state_ids[4], state_ids[3]
1326 assert res1.queue_items[0].moderation_state_id == state_ids[4]
1327 assert res1.queue_items[1].moderation_state_id == state_ids[3]
1328 assert res1.next_page_token # should have more pages
1330 # Get second page using the token
1331 res2 = api.GetModerationQueue(
1332 moderation_pb2.GetModerationQueueReq(
1333 page_size=2, newest_first=True, page_token=res1.next_page_token, item_author_user_id=normal_user.id
1334 )
1335 )
1336 assert len(res2.queue_items) == 2
1337 # Should get next newest items: state_ids[2], state_ids[1]
1338 assert res2.queue_items[0].moderation_state_id == state_ids[2]
1339 assert res2.queue_items[1].moderation_state_id == state_ids[1]
1341 # Pages should not overlap
1342 page1_ids = {item.moderation_state_id for item in res1.queue_items}
1343 page2_ids = {item.moderation_state_id for item in res2.queue_items}
1344 assert page1_ids.isdisjoint(page2_ids), "Pages should not have overlapping items"
1347def test_GetModerationLog(db):
1348 """Test getting moderation log for a state via API"""
1349 super_user, super_token = generate_user(is_superuser=True)
1350 moderator, moderator_token = generate_user(is_superuser=True)
1351 normal_user, user_token = generate_user()
1352 host, _ = generate_user()
1354 # Create a real host request
1355 state_id = create_test_host_request_with_moderation(user_token, host.id)
1357 # Perform a moderation action via API
1358 with real_moderation_session(moderator_token) as api:
1359 api.ModerateContent(
1360 moderation_pb2.ModerateContentReq(
1361 moderation_state_id=state_id,
1362 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1363 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1364 reason="Looks good",
1365 )
1366 )
1368 with real_moderation_session(super_token) as api:
1369 res = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
1370 assert len(res.log_entries) == 2 # CREATE + APPROVE
1371 assert res.moderation_state.moderation_state_id == state_id
1372 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1373 # Log entries are in reverse chronological order
1374 assert res.log_entries[0].action == moderation_pb2.MODERATION_ACTION_APPROVE
1375 assert res.log_entries[0].moderator_user_id == moderator.id
1376 assert res.log_entries[0].reason == "Looks good"
1377 assert res.log_entries[1].action == moderation_pb2.MODERATION_ACTION_CREATE
1378 assert res.log_entries[1].moderator_user_id == normal_user.id
1381def test_GetModerationLog_not_found(db):
1382 """Test getting moderation log for non-existent state"""
1383 super_user, super_token = generate_user(is_superuser=True)
1385 with real_moderation_session(super_token) as api:
1386 with pytest.raises(grpc.RpcError) as e:
1387 api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=999999))
1388 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1389 assert e.value.details() == "Moderation state not found."
1392def test_GetModerationState(db):
1393 """Test getting moderation state by object type and ID"""
1394 super_user, super_token = generate_user(is_superuser=True)
1395 user1, token1 = generate_user()
1396 user2, _ = generate_user()
1398 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1399 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1401 with requests_session(token1) as api:
1402 host_request_id = api.CreateHostRequest(
1403 requests_pb2.CreateHostRequestReq(
1404 host_user_id=user2.id,
1405 from_date=today_plus_2,
1406 to_date=today_plus_3,
1407 text=valid_request_text(),
1408 )
1409 ).host_request_id
1411 with real_moderation_session(super_token) as api:
1412 res = api.GetModerationState(
1413 moderation_pb2.GetModerationStateReq(
1414 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1415 object_id=host_request_id,
1416 )
1417 )
1418 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST
1419 assert res.moderation_state.object_id == host_request_id
1420 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1421 assert res.moderation_state.moderation_state_id > 0
1424def test_GetModerationState_not_found(db):
1425 """Test getting moderation state for non-existent object"""
1426 super_user, super_token = generate_user(is_superuser=True)
1428 with real_moderation_session(super_token) as api:
1429 with pytest.raises(grpc.RpcError) as e:
1430 api.GetModerationState(
1431 moderation_pb2.GetModerationStateReq(
1432 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1433 object_id=999999,
1434 )
1435 )
1436 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1437 assert e.value.details() == "Moderation state not found."
1440def test_GetModerationState_unspecified_type(db):
1441 """Test getting moderation state with unspecified object type"""
1442 super_user, super_token = generate_user(is_superuser=True)
1444 with real_moderation_session(super_token) as api:
1445 with pytest.raises(grpc.RpcError) as e:
1446 api.GetModerationState(
1447 moderation_pb2.GetModerationStateReq(
1448 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED,
1449 object_id=123,
1450 )
1451 )
1452 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1453 assert e.value.details() == "Object type must be specified."
1456def test_ModerateContent_approve(db):
1457 """Test approving content via unified moderation API"""
1458 super_user, super_token = generate_user(is_superuser=True)
1459 user1, token1 = generate_user()
1460 user2, _ = generate_user()
1462 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1463 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1465 # Create a host request using the API (which automatically creates moderation state)
1466 with requests_session(token1) as api:
1467 host_request_id = api.CreateHostRequest(
1468 requests_pb2.CreateHostRequestReq(
1469 host_user_id=user2.id,
1470 from_date=today_plus_2,
1471 to_date=today_plus_3,
1472 text=valid_request_text(),
1473 )
1474 ).host_request_id
1476 # Get the moderation state ID
1477 state_id = None
1478 with session_scope() as session:
1479 host_request = session.execute(
1480 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1481 ).scalar_one()
1482 state_id = host_request.moderation_state_id
1484 with real_moderation_session(super_token) as api:
1485 res = api.ModerateContent(
1486 moderation_pb2.ModerateContentReq(
1487 moderation_state_id=state_id,
1488 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1489 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1490 reason="Approved by admin",
1491 )
1492 )
1493 assert res.moderation_state.moderation_state_id == state_id
1494 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1496 # Verify state was updated in database
1497 with session_scope() as session:
1498 state = session.get_one(ModerationState, state_id)
1499 assert state.visibility == ModerationVisibility.visible
1502def test_ModerateContent_not_found(db):
1503 """Test moderating non-existent content"""
1504 super_user, super_token = generate_user(is_superuser=True)
1506 with real_moderation_session(super_token) as api:
1507 with pytest.raises(grpc.RpcError) as e:
1508 api.ModerateContent(
1509 moderation_pb2.ModerateContentReq(
1510 moderation_state_id=999999,
1511 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1512 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1513 reason="Test",
1514 )
1515 )
1516 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1517 assert e.value.details() == "Moderation state not found."
1520def test_ModerateContent_hide(db):
1521 """Test hiding content via unified moderation API"""
1522 super_user, super_token = generate_user(is_superuser=True)
1523 normal_user, user_token = generate_user()
1524 host, _ = generate_user()
1526 # Create a real host request
1527 state_id = create_test_host_request_with_moderation(user_token, host.id)
1529 with real_moderation_session(super_token) as api:
1530 res = api.ModerateContent(
1531 moderation_pb2.ModerateContentReq(
1532 moderation_state_id=state_id,
1533 action=moderation_pb2.MODERATION_ACTION_HIDE,
1534 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1535 reason="Spam content",
1536 )
1537 )
1538 assert res.moderation_state.moderation_state_id == state_id
1539 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_HIDDEN
1541 # Verify state was updated in database
1542 with session_scope() as session:
1543 state = session.get_one(ModerationState, state_id)
1544 assert state.visibility == ModerationVisibility.hidden
1547def test_ModerateContent_shadow(db):
1548 """Test shadowing content via unified moderation API"""
1549 super_user, super_token = generate_user(is_superuser=True)
1550 normal_user, user_token = generate_user()
1551 host, _ = generate_user()
1553 # Create a real host request
1554 state_id = create_test_host_request_with_moderation(user_token, host.id)
1556 with real_moderation_session(super_token) as api:
1557 res = api.ModerateContent(
1558 moderation_pb2.ModerateContentReq(
1559 moderation_state_id=state_id,
1560 action=moderation_pb2.MODERATION_ACTION_HIDE,
1561 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
1562 reason="Needs further review",
1563 )
1564 )
1565 assert res.moderation_state.moderation_state_id == state_id
1566 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1568 # Verify state was updated in database
1569 with session_scope() as session:
1570 state = session.get_one(ModerationState, state_id)
1571 assert state.visibility == ModerationVisibility.shadowed
1574def test_ModerateContent_flag(db):
1575 """Test opening a flag via the FLAG action"""
1576 super_user, super_token = generate_user(is_superuser=True)
1577 user1, token1 = generate_user()
1578 user2, _ = generate_user()
1580 # Create a host request
1581 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1582 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1584 with requests_session(token1) as api:
1585 host_request_id = api.CreateHostRequest(
1586 requests_pb2.CreateHostRequestReq(
1587 host_user_id=user2.id,
1588 from_date=today_plus_2,
1589 to_date=today_plus_3,
1590 text=valid_request_text(),
1591 )
1592 ).host_request_id
1594 # Get the moderation state ID
1595 with session_scope() as session:
1596 host_request = session.execute(
1597 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1598 ).scalar_one()
1599 state_id = host_request.moderation_state_id
1601 with real_moderation_session(super_token) as api:
1602 api.ModerateContent(
1603 moderation_pb2.ModerateContentReq(
1604 moderation_state_id=state_id,
1605 action=moderation_pb2.MODERATION_ACTION_FLAG,
1606 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
1607 reason="Admin flagged for additional review",
1608 priority=5,
1609 )
1610 )
1612 # Verify queue item was created in database with the given priority, plus a log row referencing it
1613 with session_scope() as session:
1614 queue_item = session.execute(
1615 select(ModerationQueueItem)
1616 .where(ModerationQueueItem.moderation_state_id == state_id)
1617 .where(ModerationQueueItem.trigger == ModerationTrigger.moderator_review)
1618 ).scalar_one()
1619 assert queue_item.resolved_by_log_id is None
1620 assert queue_item.priority == 5
1622 flag_log = session.execute(
1623 select(ModerationLog)
1624 .where(ModerationLog.moderation_state_id == state_id)
1625 .where(ModerationLog.action == ModerationAction.flag)
1626 ).scalar_one()
1627 assert flag_log.queue_item_id == queue_item.id
1630def test_ModerateContent_flag_requires_trigger(db):
1631 """FLAG without a trigger is rejected."""
1632 super_user, super_token = generate_user(is_superuser=True)
1633 user1, token1 = generate_user()
1634 user2, _ = generate_user()
1635 state_id = create_test_host_request_with_moderation(token1, user2.id)
1637 with real_moderation_session(super_token) as api:
1638 with pytest.raises(grpc.RpcError) as e:
1639 api.ModerateContent(
1640 moderation_pb2.ModerateContentReq(
1641 moderation_state_id=state_id,
1642 action=moderation_pb2.MODERATION_ACTION_FLAG,
1643 reason="no trigger",
1644 )
1645 )
1646 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1649def _open_queue_item_id(state_id):
1650 with session_scope() as session:
1651 return (
1652 session.execute(
1653 select(ModerationQueueItem)
1654 .where(ModerationQueueItem.moderation_state_id == state_id)
1655 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1656 )
1657 .scalars()
1658 .one()
1659 .id
1660 )
1663def test_ModerateContent_set_priority(db):
1664 """SET_PRIORITY changes a flag's priority and logs it, without touching visibility."""
1665 moderator, mod_token = generate_user(is_superuser=True)
1666 user1, token1 = generate_user()
1667 user2, _ = generate_user()
1668 state_id = create_test_host_request_with_moderation(token1, user2.id)
1669 queue_item_id = _open_queue_item_id(state_id)
1671 with real_moderation_session(mod_token) as api:
1672 api.ModerateContent(
1673 moderation_pb2.ModerateContentReq(
1674 moderation_state_id=state_id,
1675 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY,
1676 queue_item_id=queue_item_id,
1677 priority=10,
1678 reason="bump",
1679 )
1680 )
1682 with session_scope() as session:
1683 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
1684 assert queue_item.priority == 10
1685 assert queue_item.resolved_by_log_id is None # not resolved
1686 # State stays shadowed (visibility untouched)
1687 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.shadowed
1689 log = session.execute(
1690 select(ModerationLog)
1691 .where(ModerationLog.moderation_state_id == state_id)
1692 .where(ModerationLog.action == ModerationAction.set_priority)
1693 ).scalar_one()
1694 assert log.new_priority == 10
1695 assert log.queue_item_id == queue_item_id
1697 # GetModerationQueue surfaces the new priority
1698 with real_moderation_session(mod_token) as api:
1699 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1700 item = next(i for i in res.queue_items if i.queue_item_id == queue_item_id)
1701 assert item.priority == 10
1704def test_ModerateContent_unflag(db):
1705 """UNFLAG resolves a single named flag and does not change visibility."""
1706 moderator, mod_token = generate_user(is_superuser=True)
1707 user1, token1 = generate_user()
1708 user2, _ = generate_user()
1709 state_id = create_test_host_request_with_moderation(token1, user2.id)
1710 queue_item_id = _open_queue_item_id(state_id)
1712 with real_moderation_session(mod_token) as api:
1713 api.ModerateContent(
1714 moderation_pb2.ModerateContentReq(
1715 moderation_state_id=state_id,
1716 action=moderation_pb2.MODERATION_ACTION_UNFLAG,
1717 queue_item_id=queue_item_id,
1718 reason="dismissed",
1719 )
1720 )
1722 with session_scope() as session:
1723 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
1724 assert queue_item.resolved_by_log_id is not None
1725 # Visibility unchanged (still shadowed from creation)
1726 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.shadowed
1727 log = session.get_one(ModerationLog, queue_item.resolved_by_log_id)
1728 assert log.action == ModerationAction.unflag
1729 assert log.queue_item_id == queue_item_id
1732def test_ModerateContent_unflag_requires_queue_item(db):
1733 """UNFLAG without a queue_item_id is rejected."""
1734 moderator, mod_token = generate_user(is_superuser=True)
1735 user1, token1 = generate_user()
1736 user2, _ = generate_user()
1737 state_id = create_test_host_request_with_moderation(token1, user2.id)
1739 with real_moderation_session(mod_token) as api:
1740 with pytest.raises(grpc.RpcError) as e:
1741 api.ModerateContent(
1742 moderation_pb2.ModerateContentReq(
1743 moderation_state_id=state_id,
1744 action=moderation_pb2.MODERATION_ACTION_UNFLAG,
1745 reason="no target",
1746 )
1747 )
1748 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1751def test_ModerateContent_approve_without_clear_flags_leaves_flag_open(db):
1752 """APPROVE without clear_flags changes visibility but leaves open flags in the queue."""
1753 moderator, mod_token = generate_user(is_superuser=True)
1754 user1, token1 = generate_user()
1755 user2, _ = generate_user()
1756 state_id = create_test_host_request_with_moderation(token1, user2.id)
1757 queue_item_id = _open_queue_item_id(state_id)
1759 with real_moderation_session(mod_token) as api:
1760 api.ModerateContent(
1761 moderation_pb2.ModerateContentReq(
1762 moderation_state_id=state_id,
1763 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1764 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1765 reason="visible but still under review",
1766 )
1767 )
1769 with session_scope() as session:
1770 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.visible
1771 assert session.get_one(ModerationQueueItem, queue_item_id).resolved_by_log_id is None
1774def test_ModerateContent_flag_supersede(db):
1775 """FLAG with supersede_queue_item_id resolves the named flag as the new one opens."""
1776 moderator, mod_token = generate_user(is_superuser=True)
1777 user1, token1 = generate_user()
1778 user2, _ = generate_user()
1779 state_id = create_test_host_request_with_moderation(token1, user2.id)
1780 initial_item_id = _open_queue_item_id(state_id)
1782 with real_moderation_session(mod_token) as api:
1783 api.ModerateContent(
1784 moderation_pb2.ModerateContentReq(
1785 moderation_state_id=state_id,
1786 action=moderation_pb2.MODERATION_ACTION_FLAG,
1787 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG,
1788 priority=3,
1789 supersede_queue_item_id=initial_item_id,
1790 reason="machine re-flag",
1791 )
1792 )
1794 with session_scope() as session:
1795 # Old initial_review flag is resolved
1796 assert session.get_one(ModerationQueueItem, initial_item_id).resolved_by_log_id is not None
1797 # Exactly one open flag remains: the new machine_flag at priority 3
1798 open_items = (
1799 session.execute(
1800 select(ModerationQueueItem)
1801 .where(ModerationQueueItem.moderation_state_id == state_id)
1802 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1803 )
1804 .scalars()
1805 .all()
1806 )
1807 assert len(open_items) == 1
1808 assert open_items[0].trigger == ModerationTrigger.machine_flag
1809 assert open_items[0].priority == 3
1812def test_GetModerationQueue_filter_by_priority(db):
1813 """priority_min / priority_max filter the queue by priority range."""
1814 moderator, mod_token = generate_user(is_superuser=True)
1815 user1, token1 = generate_user()
1816 user2, _ = generate_user()
1817 state_id = create_test_host_request_with_moderation(token1, user2.id)
1818 queue_item_id = _open_queue_item_id(state_id)
1820 with real_moderation_session(mod_token) as api:
1821 api.ModerateContent(
1822 moderation_pb2.ModerateContentReq(
1823 moderation_state_id=state_id,
1824 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY,
1825 queue_item_id=queue_item_id,
1826 priority=7,
1827 reason="raise",
1828 )
1829 )
1831 # priority_min excludes it below the threshold, includes at/above
1832 assert len(api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_min=8)).queue_items) == 0
1833 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_min=7))
1834 assert [i.queue_item_id for i in res.queue_items] == [queue_item_id]
1836 # priority_max excludes it above the threshold
1837 assert len(api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_max=6)).queue_items) == 0
1838 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_max=7))
1839 assert [i.queue_item_id for i in res.queue_items] == [queue_item_id]
1842# ============================================================================
1843# Tests for group chat moderation
1844# ============================================================================
1847def test_group_chat_created_with_moderation_state(db):
1848 """Test that group chats are created with moderation state"""
1849 user1, token1 = generate_user()
1850 user2, _ = generate_user()
1851 make_friends(user1, user2)
1853 with conversations_session(token1) as api:
1854 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1855 group_chat_id = res.group_chat_id
1857 # Verify moderation state was created
1858 with session_scope() as session:
1859 group_chat = session.execute(select(GroupChat).where(GroupChat.conversation_id == group_chat_id)).scalar_one()
1861 assert group_chat.moderation_state.object_type == ModerationObjectType.group_chat
1862 assert group_chat.moderation_state.object_id == group_chat_id
1863 # Group chats start as SHADOWED
1864 assert group_chat.moderation_state.visibility == ModerationVisibility.shadowed
1866 # A moderation queue item should have been created
1867 queue_item = (
1868 session.execute(
1869 select(ModerationQueueItem).where(
1870 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id
1871 )
1872 )
1873 .scalars()
1874 .first()
1875 )
1876 assert queue_item is not None
1877 assert queue_item.trigger == ModerationTrigger.initial_review
1880def test_group_chat_GetModerationState(db):
1881 """Test GetModerationState API for group chats"""
1882 user1, token1 = generate_user()
1883 user2, _ = generate_user()
1884 moderator, mod_token = generate_user(is_superuser=True)
1885 make_friends(user1, user2)
1887 with conversations_session(token1) as api:
1888 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1889 group_chat_id = res.group_chat_id
1891 # Moderator can look up the moderation state
1892 with real_moderation_session(mod_token) as api:
1893 res = api.GetModerationState(
1894 moderation_pb2.GetModerationStateReq(
1895 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1896 object_id=group_chat_id,
1897 )
1898 )
1899 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT
1900 assert res.moderation_state.object_id == group_chat_id
1901 # Starts as SHADOWED
1902 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1905def test_group_chat_moderation_hide(db):
1906 """Test that a moderator can hide a group chat and participants can no longer see it"""
1907 user1, token1 = generate_user()
1908 user2, token2 = generate_user()
1909 moderator, mod_token = generate_user(is_superuser=True)
1910 make_friends(user1, user2)
1912 with conversations_session(token1) as api:
1913 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1914 group_chat_id = res.group_chat_id
1915 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
1917 # First approve the group chat so both users can see it
1918 with real_moderation_session(mod_token) as api:
1919 state_res = api.GetModerationState(
1920 moderation_pb2.GetModerationStateReq(
1921 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1922 object_id=group_chat_id,
1923 )
1924 )
1925 api.ModerateContent(
1926 moderation_pb2.ModerateContentReq(
1927 moderation_state_id=state_res.moderation_state.moderation_state_id,
1928 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1929 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1930 reason="Approved",
1931 )
1932 )
1934 # Both users can see the chat now
1935 with conversations_session(token1) as api:
1936 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1937 assert len(res.group_chats) == 1
1939 with conversations_session(token2) as api:
1940 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1941 assert len(res.group_chats) == 1
1943 # Moderator hides the group chat
1944 with real_moderation_session(mod_token) as api:
1945 state_res = api.GetModerationState(
1946 moderation_pb2.GetModerationStateReq(
1947 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1948 object_id=group_chat_id,
1949 )
1950 )
1951 api.ModerateContent(
1952 moderation_pb2.ModerateContentReq(
1953 moderation_state_id=state_res.moderation_state.moderation_state_id,
1954 action=moderation_pb2.MODERATION_ACTION_HIDE,
1955 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1956 reason="Inappropriate content",
1957 )
1958 )
1960 # Neither user can see the chat now
1961 with conversations_session(token1) as api:
1962 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1963 assert len(res.group_chats) == 0
1965 with conversations_session(token2) as api:
1966 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1967 assert len(res.group_chats) == 0
1969 # Trying to get messages returns empty (chat is hidden so no messages visible)
1970 with conversations_session(token1) as api:
1971 res = api.GetGroupChatMessages(conversations_pb2.GetGroupChatMessagesReq(group_chat_id=group_chat_id))
1972 assert len(res.messages) == 0
1975def test_group_chat_moderation_shadow(db):
1976 """Test that shadowing a group chat hides it from non-creator participants"""
1977 user1, token1 = generate_user() # Creator
1978 user2, token2 = generate_user() # Participant
1979 moderator, mod_token = generate_user(is_superuser=True)
1980 make_friends(user1, user2)
1982 with conversations_session(token1) as api:
1983 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1984 group_chat_id = res.group_chat_id
1985 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
1987 # Moderator shadows the group chat
1988 with real_moderation_session(mod_token) as api:
1989 state_res = api.GetModerationState(
1990 moderation_pb2.GetModerationStateReq(
1991 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1992 object_id=group_chat_id,
1993 )
1994 )
1995 api.ModerateContent(
1996 moderation_pb2.ModerateContentReq(
1997 moderation_state_id=state_res.moderation_state.moderation_state_id,
1998 action=moderation_pb2.MODERATION_ACTION_HIDE,
1999 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2000 reason="Needs review",
2001 )
2002 )
2004 # Creator can see SHADOWED content in list operations
2005 with conversations_session(token1) as api:
2006 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2007 assert len(res.group_chats) == 1
2008 assert res.group_chats[0].group_chat_id == group_chat_id
2010 # But non-creator participant cannot see it in lists
2011 with conversations_session(token2) as api:
2012 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2013 assert len(res.group_chats) == 0
2015 # Creator can also access it directly via GetGroupChat
2016 with conversations_session(token1) as api:
2017 res = api.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id))
2018 assert res.group_chat_id == group_chat_id
2021# ============================================================================
2022# Tests for auto-approval background job
2023# ============================================================================
2026def test_auto_approve_moderation_queue_disabled_when_zero(db, email_collector: EmailCollector):
2027 """Test that auto-approval is disabled when deadline is 0"""
2028 moderator, mod_token = generate_user(is_superuser=True)
2029 user1, token1 = generate_user()
2030 user2, token2 = generate_user()
2032 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2033 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2035 # Create a host request
2036 with requests_session(token1) as api:
2037 host_request_id = api.CreateHostRequest(
2038 requests_pb2.CreateHostRequestReq(
2039 host_user_id=user2.id,
2040 from_date=today_plus_2,
2041 to_date=today_plus_3,
2042 text=valid_request_text(),
2043 )
2044 ).host_request_id
2046 # No email should have been sent (request is shadowed)
2047 assert email_collector.count_for_recipient(user2.email) == 0
2049 # Ensure deadline is 0 (disabled)
2050 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 0
2052 # Run the job
2053 auto_approve_moderation_queue(empty_pb2.Empty())
2055 # Surfer (author) can see the request via API
2056 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2057 assert res.host_request_id == host_request_id
2059 # Author can see their SHADOWED request in their sent list
2060 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
2061 assert len(res.host_requests) == 1
2062 assert res.host_requests[0].host_request_id == host_request_id
2064 # Host cannot see the request (it's shadowed from them)
2065 with requests_session(token2) as api:
2066 with pytest.raises(grpc.RpcError) as e:
2067 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2068 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2070 # Host doesn't see it in their received list either
2071 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2072 assert len(res.host_requests) == 0
2074 # Moderator can still see the item in the moderation queue
2075 with real_moderation_session(mod_token) as api:
2076 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2077 assert len(res.queue_items) == 1
2078 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW
2080 # Moderator can check the state is still SHADOWED
2081 state_res = api.GetModerationState(
2082 moderation_pb2.GetModerationStateReq(
2083 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2084 object_id=host_request_id,
2085 )
2086 )
2087 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2090def test_auto_approve_moderation_queue_approves_old_items(
2091 db, email_collector: EmailCollector, push_collector: PushCollector
2092):
2093 """Test that auto-approval approves items older than the deadline"""
2094 moderator, mod_token = generate_user(is_superuser=True)
2095 user1, token1 = generate_user()
2096 user2, token2 = generate_user()
2098 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2099 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2101 # Create a host request
2102 with requests_session(token1) as api:
2103 host_request_id = api.CreateHostRequest(
2104 requests_pb2.CreateHostRequestReq(
2105 host_user_id=user2.id,
2106 from_date=today_plus_2,
2107 to_date=today_plus_3,
2108 text=valid_request_text("Test request for auto-approval"),
2109 )
2110 ).host_request_id
2112 # No email sent initially (shadowed)
2113 assert email_collector.count_for_recipient(user2.email) == 0
2115 # Host cannot see the request yet
2116 with requests_session(token2) as api:
2117 with pytest.raises(grpc.RpcError) as e:
2118 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2119 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2121 # Make the queue item appear old by backdating its time_created
2122 with session_scope() as session:
2123 host_request = session.execute(
2124 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
2125 ).scalar_one()
2126 queue_item = session.execute(
2127 select(ModerationQueueItem)
2128 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
2129 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2130 ).scalar_one()
2131 # Backdate the queue item by 2 minutes
2132 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=2)
2134 # Set deadline to 60 seconds (items older than 60 seconds will be auto-approved)
2135 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2136 config.MODERATION_BOT_USER_ID = moderator.id
2138 # Run the job
2139 auto_approve_moderation_queue(empty_pb2.Empty())
2141 # Now host can see the request via API
2142 with requests_session(token2) as api:
2143 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2144 assert res.host_request_id == host_request_id
2145 assert res.latest_message.text.text == valid_request_text("Test request for auto-approval")
2147 # Host sees it in their received list
2148 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2149 assert len(res.host_requests) == 1
2150 assert res.host_requests[0].host_request_id == host_request_id
2152 # Surfer sees it in their sent list
2153 with requests_session(token1) as api:
2154 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
2155 assert len(res.host_requests) == 1
2156 assert res.host_requests[0].host_request_id == host_request_id
2158 with real_moderation_session(mod_token) as api:
2159 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2160 assert len(res.queue_items) == 1
2161 flag_item = res.queue_items[0]
2162 assert flag_item.trigger == moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG
2163 assert flag_item.priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY
2165 state_res = api.GetModerationState(
2166 moderation_pb2.GetModerationStateReq(
2167 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2168 object_id=host_request_id,
2169 )
2170 )
2171 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
2173 log_res = api.GetModerationLog(
2174 moderation_pb2.GetModerationLogReq(moderation_state_id=state_res.moderation_state.moderation_state_id)
2175 )
2176 approve_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_APPROVE]
2177 assert len(approve_entries) == 1
2178 assert "Auto-approved" in approve_entries[0].reason
2179 assert "60 seconds" in approve_entries[0].reason
2180 assert approve_entries[0].moderator_user_id == moderator.id
2182 flag_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_FLAG]
2183 assert len(flag_entries) == 1
2184 assert flag_entries[0].moderator_user_id == moderator.id
2187def test_auto_approve_does_not_approve_recent_items(db, email_collector: EmailCollector):
2188 """Test that auto-approval does not approve items that are newer than the deadline"""
2189 moderator, mod_token = generate_user(is_superuser=True)
2190 user1, token1 = generate_user()
2191 user2, token2 = generate_user()
2193 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2194 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2196 # Create a host request
2197 with requests_session(token1) as api:
2198 host_request_id = api.CreateHostRequest(
2199 requests_pb2.CreateHostRequestReq(
2200 host_user_id=user2.id,
2201 from_date=today_plus_2,
2202 to_date=today_plus_3,
2203 text=valid_request_text(),
2204 )
2205 ).host_request_id
2207 # No email sent (shadowed)
2208 assert email_collector.count_for_recipient(user2.email) == 0
2210 # Set deadline to 1 hour (items older than 1 hour will be auto-approved)
2211 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 3600
2212 config.MODERATION_BOT_USER_ID = moderator.id
2214 # Run the job - the item was just created, so it shouldn't be approved
2215 auto_approve_moderation_queue(empty_pb2.Empty())
2217 # Still no email sent
2218 assert email_collector.count_for_recipient(user2.email) == 0
2220 # Host still cannot see the request
2221 with requests_session(token2) as api:
2222 with pytest.raises(grpc.RpcError) as e:
2223 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2224 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2226 # Not in host's received list
2227 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2228 assert len(res.host_requests) == 0
2230 # Moderator sees it still in queue unresolved
2231 with real_moderation_session(mod_token) as api:
2232 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2233 assert len(res.queue_items) == 1
2235 # State is still SHADOWED
2236 state_res = api.GetModerationState(
2237 moderation_pb2.GetModerationStateReq(
2238 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2239 object_id=host_request_id,
2240 )
2241 )
2242 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2245def test_auto_approve_does_not_approve_already_approved(db):
2246 """Test that auto-approval does not re-approve already visible content"""
2247 moderator, mod_token = generate_user(is_superuser=True)
2248 user1, token1 = generate_user()
2249 user2, token2 = generate_user()
2251 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2252 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2254 # Create a host request
2255 with requests_session(token1) as api:
2256 host_request_id = api.CreateHostRequest(
2257 requests_pb2.CreateHostRequestReq(
2258 host_user_id=user2.id,
2259 from_date=today_plus_2,
2260 to_date=today_plus_3,
2261 text=valid_request_text(),
2262 )
2263 ).host_request_id
2265 # Moderator approves it manually
2266 with real_moderation_session(mod_token) as api:
2267 state_res = api.GetModerationState(
2268 moderation_pb2.GetModerationStateReq(
2269 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2270 object_id=host_request_id,
2271 )
2272 )
2273 state_id = state_res.moderation_state.moderation_state_id
2275 api.ModerateContent(
2276 moderation_pb2.ModerateContentReq(
2277 moderation_state_id=state_id,
2278 action=moderation_pb2.MODERATION_ACTION_APPROVE,
2279 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2280 reason="Approved by moderator",
2281 clear_flags=True,
2282 )
2283 )
2285 # Host can now see it
2286 with requests_session(token2) as api:
2287 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2288 assert res.host_request_id == host_request_id
2290 # Get log count before auto-approval
2291 with real_moderation_session(mod_token) as api:
2292 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2293 log_count_before = len(log_res_before.log_entries)
2295 # Set deadline to 1 second
2296 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1
2297 config.MODERATION_BOT_USER_ID = moderator.id
2299 # Run the job
2300 auto_approve_moderation_queue(empty_pb2.Empty())
2302 # No new log entries should be created (already approved, queue item resolved)
2303 with real_moderation_session(mod_token) as api:
2304 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2305 assert len(log_res_after.log_entries) == log_count_before
2307 # Queue should be empty (item was resolved when moderator approved)
2308 queue_res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2309 assert len(queue_res.queue_items) == 0
2312def test_auto_approve_does_not_approve_moderator_shadowed_items(db):
2313 """Test that auto-approval does not approve items that were explicitly shadowed by a moderator"""
2314 moderator, mod_token = generate_user(is_superuser=True)
2315 user1, token1 = generate_user()
2316 user2, token2 = generate_user()
2318 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2319 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2321 # Create a host request
2322 with requests_session(token1) as api:
2323 host_request_id = api.CreateHostRequest(
2324 requests_pb2.CreateHostRequestReq(
2325 host_user_id=user2.id,
2326 from_date=today_plus_2,
2327 to_date=today_plus_3,
2328 text=valid_request_text(),
2329 )
2330 ).host_request_id
2332 # Moderator explicitly shadows the content (keeping it shadowed but resolving the queue item)
2333 with real_moderation_session(mod_token) as api:
2334 state_res = api.GetModerationState(
2335 moderation_pb2.GetModerationStateReq(
2336 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2337 object_id=host_request_id,
2338 )
2339 )
2340 state_id = state_res.moderation_state.moderation_state_id
2342 # Set to SHADOWED explicitly with clear_flags - this resolves the INITIAL_REVIEW queue item
2343 api.ModerateContent(
2344 moderation_pb2.ModerateContentReq(
2345 moderation_state_id=state_id,
2346 action=moderation_pb2.MODERATION_ACTION_HIDE,
2347 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2348 reason="Keeping shadowed for review",
2349 clear_flags=True,
2350 )
2351 )
2353 # Backdate to ensure it would be old enough for auto-approval
2354 with session_scope() as session:
2355 queue_item = session.execute(
2356 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
2357 ).scalar_one()
2358 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10)
2360 # Set deadline to 1 second
2361 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1
2362 config.MODERATION_BOT_USER_ID = moderator.id
2364 # Get log count before
2365 with real_moderation_session(mod_token) as api:
2366 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2367 log_count_before = len(log_res_before.log_entries)
2369 # Run the job
2370 auto_approve_moderation_queue(empty_pb2.Empty())
2372 # No new log entries - the queue item was resolved when moderator shadowed it
2373 with real_moderation_session(mod_token) as api:
2374 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2375 assert len(log_res_after.log_entries) == log_count_before
2377 # State should still be SHADOWED (not auto-approved to VISIBLE)
2378 state_res = api.GetModerationState(
2379 moderation_pb2.GetModerationStateReq(
2380 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2381 object_id=host_request_id,
2382 )
2383 )
2384 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2386 # Host still cannot see the request
2387 with requests_session(token2) as api:
2388 with pytest.raises(grpc.RpcError) as e:
2389 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2390 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2393def test_auto_approve_skips_shadowed_user_authored_items(db):
2394 """Auto-approval must not promote content authored by a currently-shadowed user."""
2395 moderator, mod_token = generate_user(is_superuser=True)
2396 surfer, surfer_token = generate_user()
2397 host, host_token = generate_user()
2399 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2400 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2402 with requests_session(surfer_token) as api:
2403 host_request_id = api.CreateHostRequest(
2404 requests_pb2.CreateHostRequestReq(
2405 host_user_id=host.id,
2406 from_date=today_plus_2,
2407 to_date=today_plus_3,
2408 text=valid_request_text(),
2409 )
2410 ).host_request_id
2412 # Shadow the surfer after the host request was created
2413 with session_scope() as session:
2414 session.execute(select(User).where(User.id == surfer.id)).scalar_one().shadowed_at = now()
2416 # Backdate the queue item to make it eligible for auto-approval
2417 with session_scope() as session:
2418 host_request = session.execute(
2419 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
2420 ).scalar_one()
2421 queue_item = session.execute(
2422 select(ModerationQueueItem)
2423 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
2424 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2425 ).scalar_one()
2426 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10)
2428 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2429 config.MODERATION_BOT_USER_ID = moderator.id
2431 auto_approve_moderation_queue(empty_pb2.Empty())
2433 # State should remain SHADOWED — the auto-approve job must skip shadowed-user content
2434 with real_moderation_session(mod_token) as api:
2435 state_res = api.GetModerationState(
2436 moderation_pb2.GetModerationStateReq(
2437 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2438 object_id=host_request_id,
2439 )
2440 )
2441 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2443 # Host still cannot see the request
2444 with requests_session(host_token) as api:
2445 with pytest.raises(grpc.RpcError) as e:
2446 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2447 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2449 # But the (shadowed) author can still see their own request
2450 with requests_session(surfer_token) as api:
2451 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2452 assert res.host_request_id == host_request_id
2455def test_auto_approve_preserves_other_open_flags(db):
2456 """Auto-approval supersedes only the INITIAL_REVIEW item; other open flags survive and a high-prio flag is added."""
2457 moderator, mod_token = generate_user(is_superuser=True)
2458 surfer, surfer_token = generate_user()
2459 host, _ = generate_user()
2461 state_id = create_test_host_request_with_moderation(surfer_token, host.id)
2463 with real_moderation_session(mod_token) as api:
2464 api.ModerateContent(
2465 moderation_pb2.ModerateContentReq(
2466 moderation_state_id=state_id,
2467 action=moderation_pb2.MODERATION_ACTION_FLAG,
2468 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
2469 priority=5,
2470 reason="user reported this",
2471 )
2472 )
2474 with session_scope() as session:
2475 initial_item = session.execute(
2476 select(ModerationQueueItem)
2477 .where(ModerationQueueItem.moderation_state_id == state_id)
2478 .where(ModerationQueueItem.trigger == ModerationTrigger.initial_review)
2479 ).scalar_one()
2480 initial_item_id = initial_item.id
2481 initial_item.time_created = datetime.now(initial_item.time_created.tzinfo) - timedelta(minutes=10)
2483 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2484 config.MODERATION_BOT_USER_ID = moderator.id
2486 auto_approve_moderation_queue(empty_pb2.Empty())
2488 with session_scope() as session:
2489 assert (
2490 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.id == initial_item_id))
2491 .scalar_one()
2492 .resolved_by_log_id
2493 is not None
2494 )
2495 assert (
2496 session.execute(select(ModerationState).where(ModerationState.id == state_id)).scalar_one().visibility
2497 == ModerationVisibility.visible
2498 )
2500 open_items = (
2501 session.execute(
2502 select(ModerationQueueItem)
2503 .where(ModerationQueueItem.moderation_state_id == state_id)
2504 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2505 )
2506 .scalars()
2507 .all()
2508 )
2509 open_by_trigger = {item.trigger: item for item in open_items}
2510 assert open_by_trigger.keys() == {ModerationTrigger.user_flag, ModerationTrigger.machine_flag}
2511 assert open_by_trigger[ModerationTrigger.user_flag].priority == 5
2512 assert open_by_trigger[ModerationTrigger.machine_flag].priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY
2515# ============================================================================
2516# Notification Suppression Tests
2517# ============================================================================
2520def test_host_request_message_notifications_suppressed_before_approval(
2521 db, email_collector: EmailCollector, push_collector: PushCollector, moderator
2522):
2523 """
2524 Test that notifications are NOT sent for messages in host requests
2525 that haven't been approved yet.
2526 """
2527 host, host_token = generate_user(complete_profile=True)
2528 surfer, surfer_token = generate_user(complete_profile=True)
2530 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2531 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2533 # Create host request (it starts in SHADOWED state)
2534 with requests_session(surfer_token) as api:
2535 hr_id = api.CreateHostRequest(
2536 requests_pb2.CreateHostRequestReq(
2537 host_user_id=host.id,
2538 from_date=today_plus_2,
2539 to_date=today_plus_3,
2540 text=valid_request_text("Initial request message"),
2541 )
2542 ).host_request_id
2544 # No notifications should have been sent to the host (request is SHADOWED)
2545 assert email_collector.count_for_recipient(host.email) == 0
2546 assert push_collector.count_for_user(host.id) == 0
2548 # Send additional messages BEFORE approval - should NOT generate notifications
2549 with requests_session(surfer_token) as api:
2550 api.SendHostRequestMessage(
2551 requests_pb2.SendHostRequestMessageReq(
2552 host_request_id=hr_id,
2553 text="Follow-up message 1",
2554 )
2555 )
2556 api.SendHostRequestMessage(
2557 requests_pb2.SendHostRequestMessageReq(
2558 host_request_id=hr_id,
2559 text="Follow-up message 2",
2560 )
2561 )
2563 # Host should STILL have no notifications (messages sent while SHADOWED)
2564 assert email_collector.count_for_recipient(host.email) == 0
2565 assert push_collector.count_for_user(host.id) == 0
2567 # Now approve the request
2568 moderator.approve_host_request(hr_id)
2569 email_collector.pop_for_recipient(host.email)
2571 # Host should now have 3 notifications (all deferred notifications are delivered on approval):
2572 # 1. host_request:create (the initial request)
2573 # 2. host_request:message (Follow-up message 1)
2574 # 3. host_request:message (Follow-up message 2)
2575 assert push_collector.count_for_user(host.id) == 3
2576 push = push_collector.pop_for_user(host.id, last=False)
2577 assert push.content.title == f"New host request from {surfer.name}"
2580def test_host_request_status_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2581 """
2582 Test that status change notifications (accept/reject/etc.) are NOT sent
2583 for host requests that haven't been approved yet.
2585 Note: In practice, the host can't even SEE the request to accept/reject it
2586 when it's SHADOWED. But if they somehow did, we still shouldn't notify.
2587 """
2588 host, host_token = generate_user(complete_profile=True)
2589 surfer, surfer_token = generate_user(complete_profile=True)
2591 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2592 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2594 # Create host request
2595 with requests_session(surfer_token) as api:
2596 hr_id = api.CreateHostRequest(
2597 requests_pb2.CreateHostRequestReq(
2598 host_user_id=host.id,
2599 from_date=today_plus_2,
2600 to_date=today_plus_3,
2601 text=valid_request_text(),
2602 )
2603 ).host_request_id
2605 # No notifications should have been sent to the host (request is SHADOWED)
2606 assert push_collector.count_for_user(host.id) == 0
2608 # The surfer can cancel their own request even when SHADOWED
2609 # But this should NOT notify the host since the request isn't approved
2610 with requests_session(surfer_token) as api:
2611 api.RespondHostRequest(
2612 requests_pb2.RespondHostRequestReq(
2613 host_request_id=hr_id,
2614 status=messages_pb2.HOST_REQUEST_STATUS_CANCELLED,
2615 text="Actually, never mind",
2616 )
2617 )
2619 # Host should STILL have no notifications (cancel notification suppressed)
2620 assert push_collector.count_for_user(host.id) == 0
2623def test_host_request_notifications_sent_after_approval(
2624 db, email_collector: EmailCollector, push_collector: PushCollector, moderator
2625):
2626 """
2627 Test that after a host request is approved, all notifications work normally.
2628 """
2629 host, host_token = generate_user(complete_profile=True)
2630 surfer, surfer_token = generate_user(complete_profile=True)
2632 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2633 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2635 # Create and approve host request
2636 with requests_session(surfer_token) as api:
2637 hr_id = api.CreateHostRequest(
2638 requests_pb2.CreateHostRequestReq(
2639 host_user_id=host.id,
2640 from_date=today_plus_2,
2641 to_date=today_plus_3,
2642 text=valid_request_text(),
2643 )
2644 ).host_request_id
2646 moderator.approve_host_request(hr_id)
2648 # Host should have received 1 notification (the approval notification)
2649 email_collector.pop_for_recipient(host.email, last=True)
2650 push_collector.pop_for_user(host.id, last=True)
2652 # Host accepts the request - surfer should be notified
2653 with requests_session(host_token) as api:
2654 api.RespondHostRequest(
2655 requests_pb2.RespondHostRequestReq(
2656 host_request_id=hr_id,
2657 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED,
2658 text="Sure, come on over!",
2659 )
2660 )
2662 # Surfer should have 1 notification (the accept notification)
2663 email_collector.pop_for_recipient(surfer.email, last=True)
2664 push = push_collector.pop_for_user(surfer.id, last=True)
2665 assert push.content.title == f"{host.name} accepted your host request"
2667 # Surfer confirms - host should be notified
2668 with requests_session(surfer_token) as api:
2669 api.RespondHostRequest(
2670 requests_pb2.RespondHostRequestReq(
2671 host_request_id=hr_id,
2672 status=messages_pb2.HOST_REQUEST_STATUS_CONFIRMED,
2673 text="See you then!",
2674 )
2675 )
2677 # Host should now have received the confirmation notifications
2678 email_collector.pop_for_recipient(host.email, last=True)
2679 push = push_collector.pop_for_user(host.id, last=True)
2680 assert push.content.title == f"{surfer.name} confirmed their host request"
2683def test_group_chat_message_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2684 """
2685 Test that notifications are NOT sent for messages in group chats
2686 that haven't been approved yet.
2687 """
2688 user1, token1 = generate_user(complete_profile=True)
2689 user2, token2 = generate_user(complete_profile=True)
2691 # Create a group chat (starts in SHADOWED state)
2692 with conversations_session(token1) as api:
2693 res = api.CreateGroupChat(
2694 conversations_pb2.CreateGroupChatReq(
2695 recipient_user_ids=[user2.id],
2696 )
2697 )
2698 gc_id = res.group_chat_id
2700 # Verify initial state
2701 with session_scope() as session:
2702 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2703 assert gc.moderation_state.visibility == ModerationVisibility.shadowed
2705 # No notifications should have been sent yet (chat is SHADOWED)
2706 assert push_collector.count_for_user(user2.id) == 0
2708 # Send messages BEFORE approval
2709 with conversations_session(token1) as api:
2710 api.SendMessage(
2711 conversations_pb2.SendMessageReq(
2712 group_chat_id=gc_id,
2713 text="Hello before approval",
2714 )
2715 )
2717 # Process the queued notification job
2718 while process_job():
2719 pass
2721 # User2 should STILL have no notifications (chat is SHADOWED)
2722 assert push_collector.count_for_user(user2.id) == 0
2724 # Now approve the group chat
2725 moderator.approve_group_chat(gc_id)
2727 # Process the queued notification jobs from approval
2728 while process_job():
2729 pass
2731 # Verify moderation state after approval
2732 with session_scope() as session:
2733 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2734 assert gc.moderation_state.visibility == ModerationVisibility.visible
2736 # User2 should have received 1 notification for the first message sent before approval
2737 push = push_collector.pop_for_user(user2.id, last=True)
2738 assert push.content.title == user1.name
2739 assert push.content.body == "Hello before approval"
2741 # Send a message AFTER approval
2742 with conversations_session(token1) as api:
2743 api.SendMessage(
2744 conversations_pb2.SendMessageReq(
2745 group_chat_id=gc_id,
2746 text="Hello after approval",
2747 )
2748 )
2750 # Process the queued notification job
2751 while process_job():
2752 pass
2754 # User2 should have received another notification
2755 assert push_collector.count_for_user(user2.id) == 1
2758def test_event_moderation_state_content(db):
2759 """Test that event moderation state content includes both title and description"""
2760 super_user, super_token = generate_user(is_superuser=True)
2761 user, token = generate_user()
2763 with session_scope() as session:
2764 create_community(session, 0, 2, "Community", [user], [], None)
2766 start_time = now() + timedelta(hours=2)
2767 end_time = start_time + timedelta(hours=3)
2769 with events_session(token) as api:
2770 res = api.CreateEvent(
2771 events_pb2.CreateEventReq(
2772 title="My Event Title",
2773 content="My event description.",
2774 photo_key=None,
2775 location=events_pb2.EventLocation(
2776 address="Near Null Island",
2777 lat=0.1,
2778 lng=0.2,
2779 ),
2780 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time),
2781 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time),
2782 )
2783 )
2784 event_id = res.event_id
2786 with session_scope() as session:
2787 occurrence = session.execute(select(EventOccurrence).where(EventOccurrence.id == event_id)).scalar_one()
2788 state_id = occurrence.moderation_state_id
2790 with real_moderation_session(super_token) as api:
2791 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
2792 event_items = [
2793 item
2794 for item in res.queue_items
2795 if item.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE
2796 ]
2797 assert len(event_items) == 1
2798 assert event_items[0].moderation_state.content == "My Event Title\n\nMy event description."
2801# ============================================================================
2802# Tests for SetUserContentVisibility
2803# ============================================================================
2806def _get_moderation_state(session, object_type, object_id):
2807 return session.execute(
2808 select(ModerationState)
2809 .where(ModerationState.object_type == object_type)
2810 .where(ModerationState.object_id == object_id)
2811 ).scalar_one()
2814def test_SetUserContentVisibility_host_request(db):
2815 super_user, super_token = generate_user(is_superuser=True)
2816 surfer, surfer_token = generate_user()
2817 host, _ = generate_user()
2819 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2820 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2821 with requests_session(surfer_token) as api:
2822 hr_id = api.CreateHostRequest(
2823 requests_pb2.CreateHostRequestReq(
2824 host_user_id=host.id,
2825 from_date=today_plus_2,
2826 to_date=today_plus_3,
2827 text=valid_request_text(),
2828 )
2829 ).host_request_id
2831 with real_moderation_session(super_token) as api:
2832 res = api.SetUserContentVisibility(
2833 moderation_pb2.SetUserContentVisibilityReq(
2834 user_id=surfer.id,
2835 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2836 )
2837 )
2838 # Already shadowed by default — no-op
2839 assert res.updated_count == 0
2841 with real_moderation_session(super_token) as api:
2842 res = api.SetUserContentVisibility(
2843 moderation_pb2.SetUserContentVisibilityReq(
2844 user_id=surfer.id,
2845 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2846 reason="policy violation",
2847 )
2848 )
2849 assert res.updated_count == 1
2851 with session_scope() as session:
2852 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
2853 assert state.visibility == ModerationVisibility.hidden
2855 log_entries = (
2856 session.execute(
2857 select(ModerationLog)
2858 .where(ModerationLog.moderation_state_id == state.id)
2859 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc())
2860 )
2861 .scalars()
2862 .all()
2863 )
2864 # create log + bulk update log
2865 assert len(log_entries) == 2
2866 assert log_entries[-1].new_visibility == ModerationVisibility.hidden
2867 assert log_entries[-1].moderator_user_id == super_user.id
2868 assert log_entries[-1].reason == "policy violation"
2871def test_SetUserContentVisibility_group_chat(db):
2872 super_user, super_token = generate_user(is_superuser=True)
2873 creator, creator_token = generate_user()
2874 other, _ = generate_user()
2875 make_friends(creator, other)
2877 with conversations_session(creator_token) as api:
2878 gc_id = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[other.id])).group_chat_id
2880 with real_moderation_session(super_token) as api:
2881 api.SetUserContentVisibility(
2882 moderation_pb2.SetUserContentVisibilityReq(
2883 user_id=creator.id,
2884 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2885 )
2886 )
2888 with session_scope() as session:
2889 state = _get_moderation_state(session, ModerationObjectType.group_chat, gc_id)
2890 assert state.visibility == ModerationVisibility.hidden
2893def test_SetUserContentVisibility_event_occurrence(db):
2894 super_user, super_token = generate_user(is_superuser=True)
2895 creator, creator_token = generate_user()
2897 with session_scope() as session:
2898 create_community(session, 0, 2, "Community", [creator], [], None)
2900 start_time = now() + timedelta(hours=2)
2901 end_time = start_time + timedelta(hours=3)
2902 with events_session(creator_token) as api:
2903 event_id = api.CreateEvent(
2904 events_pb2.CreateEventReq(
2905 title="Event",
2906 content="Event description.",
2907 photo_key=None,
2908 location=events_pb2.EventLocation(
2909 address="Near Null Island",
2910 lat=0.1,
2911 lng=0.2,
2912 ),
2913 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time),
2914 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time),
2915 )
2916 ).event_id
2918 with real_moderation_session(super_token) as api:
2919 res = api.SetUserContentVisibility(
2920 moderation_pb2.SetUserContentVisibilityReq(
2921 user_id=creator.id,
2922 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2923 )
2924 )
2925 assert res.updated_count == 1
2927 with session_scope() as session:
2928 state = _get_moderation_state(session, ModerationObjectType.event_occurrence, event_id)
2929 assert state.visibility == ModerationVisibility.hidden
2932def test_SetUserContentVisibility_friend_request(db):
2933 super_user, super_token = generate_user(is_superuser=True)
2934 sender, sender_token = generate_user()
2935 recipient, _ = generate_user()
2937 with api_session(sender_token) as api:
2938 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=recipient.id))
2940 with session_scope() as session:
2941 fr_id = session.execute(
2942 select(FriendRelationship.id).where(FriendRelationship.from_user_id == sender.id)
2943 ).scalar_one()
2945 with real_moderation_session(super_token) as api:
2946 res = api.SetUserContentVisibility(
2947 moderation_pb2.SetUserContentVisibilityReq(
2948 user_id=sender.id,
2949 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2950 )
2951 )
2952 assert res.updated_count == 1
2954 with session_scope() as session:
2955 state = _get_moderation_state(session, ModerationObjectType.friend_request, fr_id)
2956 assert state.visibility == ModerationVisibility.hidden
2959def test_SetUserContentVisibility_round_trip(db):
2960 super_user, super_token = generate_user(is_superuser=True)
2961 surfer, surfer_token = generate_user()
2962 host, _ = generate_user()
2964 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2965 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2966 with requests_session(surfer_token) as api:
2967 hr_id = api.CreateHostRequest(
2968 requests_pb2.CreateHostRequestReq(
2969 host_user_id=host.id,
2970 from_date=today_plus_2,
2971 to_date=today_plus_3,
2972 text=valid_request_text(),
2973 )
2974 ).host_request_id
2976 with real_moderation_session(super_token) as api:
2977 api.SetUserContentVisibility(
2978 moderation_pb2.SetUserContentVisibilityReq(
2979 user_id=surfer.id,
2980 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2981 reason="first",
2982 )
2983 )
2984 api.SetUserContentVisibility(
2985 moderation_pb2.SetUserContentVisibilityReq(
2986 user_id=surfer.id,
2987 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2988 reason="second",
2989 )
2990 )
2992 with session_scope() as session:
2993 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
2994 assert state.visibility == ModerationVisibility.visible
2996 bulk_log_entries = (
2997 session.execute(
2998 select(ModerationLog)
2999 .where(ModerationLog.moderation_state_id == state.id)
3000 .where(ModerationLog.reason.in_(("first", "second")))
3001 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc())
3002 )
3003 .scalars()
3004 .all()
3005 )
3006 assert [entry.new_visibility for entry in bulk_log_entries] == [
3007 ModerationVisibility.hidden,
3008 ModerationVisibility.visible,
3009 ]
3012def test_SetUserContentVisibility_resolves_queue_items(db):
3013 super_user, super_token = generate_user(is_superuser=True)
3014 surfer, surfer_token = generate_user()
3015 host, _ = generate_user()
3017 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3018 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3019 with requests_session(surfer_token) as api:
3020 hr_id = api.CreateHostRequest(
3021 requests_pb2.CreateHostRequestReq(
3022 host_user_id=host.id,
3023 from_date=today_plus_2,
3024 to_date=today_plus_3,
3025 text=valid_request_text(),
3026 )
3027 ).host_request_id
3029 with session_scope() as session:
3030 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3031 queue_item = session.execute(
3032 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id)
3033 ).scalar_one()
3034 assert queue_item.resolved_by_log_id is None
3036 with real_moderation_session(super_token) as api:
3037 api.SetUserContentVisibility(
3038 moderation_pb2.SetUserContentVisibilityReq(
3039 user_id=surfer.id,
3040 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3041 )
3042 )
3044 with session_scope() as session:
3045 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3046 queue_item = session.execute(
3047 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id)
3048 ).scalar_one()
3049 assert queue_item.resolved_by_log_id is not None
3052def test_SetUserContentVisibility_noop_when_matches(db):
3053 super_user, super_token = generate_user(is_superuser=True)
3054 surfer, surfer_token = generate_user()
3055 host, _ = generate_user()
3057 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3058 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3059 with requests_session(surfer_token) as api:
3060 api.CreateHostRequest(
3061 requests_pb2.CreateHostRequestReq(
3062 host_user_id=host.id,
3063 from_date=today_plus_2,
3064 to_date=today_plus_3,
3065 text=valid_request_text(),
3066 )
3067 )
3069 with session_scope() as session:
3070 log_count_before = len(session.execute(select(ModerationLog)).scalars().all())
3072 with real_moderation_session(super_token) as api:
3073 res = api.SetUserContentVisibility(
3074 moderation_pb2.SetUserContentVisibilityReq(
3075 user_id=surfer.id,
3076 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3077 )
3078 )
3079 assert res.updated_count == 0
3081 with session_scope() as session:
3082 log_count_after = len(session.execute(select(ModerationLog)).scalars().all())
3083 assert log_count_after == log_count_before
3086def test_SetUserContentVisibility_unspecified_rejected(db):
3087 super_user, super_token = generate_user(is_superuser=True)
3088 target, _ = generate_user()
3090 with real_moderation_session(super_token) as api:
3091 with pytest.raises(grpc.RpcError) as e:
3092 api.SetUserContentVisibility(
3093 moderation_pb2.SetUserContentVisibilityReq(
3094 user_id=target.id,
3095 visibility=moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED,
3096 )
3097 )
3098 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
3101def test_SetUserContentVisibility_non_admin_rejected(db):
3102 normal_user, normal_token = generate_user()
3103 target, _ = generate_user()
3105 with real_moderation_session(normal_token) as api:
3106 with pytest.raises(grpc.RpcError) as e:
3107 api.SetUserContentVisibility(
3108 moderation_pb2.SetUserContentVisibilityReq(
3109 user_id=target.id,
3110 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3111 )
3112 )
3113 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED
3116def test_SetUserContentVisibility_writes_admin_action(db):
3117 super_user, super_token = generate_user(is_superuser=True)
3118 surfer, surfer_token = generate_user()
3119 host, _ = generate_user()
3121 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3122 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3123 with requests_session(surfer_token) as api:
3124 api.CreateHostRequest(
3125 requests_pb2.CreateHostRequestReq(
3126 host_user_id=host.id,
3127 from_date=today_plus_2,
3128 to_date=today_plus_3,
3129 text=valid_request_text(),
3130 )
3131 )
3133 with real_moderation_session(super_token) as api:
3134 api.SetUserContentVisibility(
3135 moderation_pb2.SetUserContentVisibilityReq(
3136 user_id=surfer.id,
3137 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3138 reason="bulk hide",
3139 )
3140 )
3142 with session_scope() as session:
3143 actions = (
3144 session.execute(
3145 select(AdminAction)
3146 .where(AdminAction.target_user_id == surfer.id)
3147 .where(AdminAction.action_type == "set_user_content_visibility")
3148 )
3149 .scalars()
3150 .all()
3151 )
3152 assert len(actions) == 1
3153 assert actions[0].admin_user_id == super_user.id
3154 assert actions[0].tag == "hidden"
3155 assert actions[0].note == "bulk hide"
3158def test_SetUserContentVisibility_only_touches_target(db):
3159 super_user, super_token = generate_user(is_superuser=True)
3160 target, target_token = generate_user()
3161 other, other_token = generate_user()
3162 host, _ = generate_user()
3164 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3165 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3167 with requests_session(target_token) as api:
3168 target_hr_id = api.CreateHostRequest(
3169 requests_pb2.CreateHostRequestReq(
3170 host_user_id=host.id,
3171 from_date=today_plus_2,
3172 to_date=today_plus_3,
3173 text=valid_request_text(),
3174 )
3175 ).host_request_id
3177 with requests_session(other_token) as api:
3178 other_hr_id = api.CreateHostRequest(
3179 requests_pb2.CreateHostRequestReq(
3180 host_user_id=host.id,
3181 from_date=today_plus_2,
3182 to_date=today_plus_3,
3183 text=valid_request_text(),
3184 )
3185 ).host_request_id
3187 with real_moderation_session(super_token) as api:
3188 res = api.SetUserContentVisibility(
3189 moderation_pb2.SetUserContentVisibilityReq(
3190 user_id=target.id,
3191 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3192 )
3193 )
3194 assert res.updated_count == 1
3196 with session_scope() as session:
3197 target_state = _get_moderation_state(session, ModerationObjectType.host_request, target_hr_id)
3198 other_state = _get_moderation_state(session, ModerationObjectType.host_request, other_hr_id)
3199 assert target_state.visibility == ModerationVisibility.hidden
3200 assert other_state.visibility == ModerationVisibility.shadowed
3203def test_SetUserContentVisibility_user_not_found(db):
3204 super_user, super_token = generate_user(is_superuser=True)
3206 with real_moderation_session(super_token) as api:
3207 with pytest.raises(grpc.RpcError) as e:
3208 api.SetUserContentVisibility(
3209 moderation_pb2.SetUserContentVisibilityReq(
3210 user_id=999999,
3211 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3212 )
3213 )
3214 assert e.value.code() == grpc.StatusCode.NOT_FOUND
3217def test_SetUserContentVisibility_from_visibility_filter(db):
3218 super_user, super_token = generate_user(is_superuser=True)
3219 surfer, surfer_token = generate_user()
3220 host, _ = generate_user()
3222 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3223 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3224 with requests_session(surfer_token) as api:
3225 hr_id = api.CreateHostRequest(
3226 requests_pb2.CreateHostRequestReq(
3227 host_user_id=host.id,
3228 from_date=today_plus_2,
3229 to_date=today_plus_3,
3230 text=valid_request_text(),
3231 )
3232 ).host_request_id
3234 with real_moderation_session(super_token) as api:
3235 api.SetUserContentVisibility(
3236 moderation_pb2.SetUserContentVisibilityReq(
3237 user_id=surfer.id,
3238 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3239 )
3240 )
3242 with real_moderation_session(super_token) as api:
3243 res = api.SetUserContentVisibility(
3244 moderation_pb2.SetUserContentVisibilityReq(
3245 user_id=surfer.id,
3246 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3247 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_SHADOWED],
3248 )
3249 )
3250 assert res.updated_count == 0
3252 with session_scope() as session:
3253 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3254 assert state.visibility == ModerationVisibility.visible
3256 with real_moderation_session(super_token) as api:
3257 res = api.SetUserContentVisibility(
3258 moderation_pb2.SetUserContentVisibilityReq(
3259 user_id=surfer.id,
3260 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3261 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_VISIBLE],
3262 )
3263 )
3264 assert res.updated_count == 1
3266 with session_scope() as session:
3267 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3268 assert state.visibility == ModerationVisibility.shadowed
3271def test_SetUserContentVisibility_from_visibility_multi(db):
3272 super_user, super_token = generate_user(is_superuser=True)
3273 surfer, surfer_token = generate_user()
3274 host1, _ = generate_user()
3275 host2, _ = generate_user()
3277 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3278 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3279 with requests_session(surfer_token) as api:
3280 hr1_id = api.CreateHostRequest(
3281 requests_pb2.CreateHostRequestReq(
3282 host_user_id=host1.id,
3283 from_date=today_plus_2,
3284 to_date=today_plus_3,
3285 text=valid_request_text(),
3286 )
3287 ).host_request_id
3288 hr2_id = api.CreateHostRequest(
3289 requests_pb2.CreateHostRequestReq(
3290 host_user_id=host2.id,
3291 from_date=today_plus_2,
3292 to_date=today_plus_3,
3293 text=valid_request_text(),
3294 )
3295 ).host_request_id
3297 with session_scope() as session:
3298 state1_id = _get_moderation_state(session, ModerationObjectType.host_request, hr1_id).id
3300 with real_moderation_session(super_token) as api:
3301 api.ModerateContent(
3302 moderation_pb2.ModerateContentReq(
3303 moderation_state_id=state1_id,
3304 action=moderation_pb2.MODERATION_ACTION_APPROVE,
3305 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3306 )
3307 )
3309 with real_moderation_session(super_token) as api:
3310 res = api.SetUserContentVisibility(
3311 moderation_pb2.SetUserContentVisibilityReq(
3312 user_id=surfer.id,
3313 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3314 from_visibility=[
3315 moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3316 moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3317 ],
3318 )
3319 )
3320 assert res.updated_count == 2
3322 with session_scope() as session:
3323 state1 = _get_moderation_state(session, ModerationObjectType.host_request, hr1_id)
3324 state2 = _get_moderation_state(session, ModerationObjectType.host_request, hr2_id)
3325 assert state1.visibility == ModerationVisibility.hidden
3326 assert state2.visibility == ModerationVisibility.hidden
3329def test_SetUserContentVisibility_from_visibility_empty_is_any(db):
3330 super_user, super_token = generate_user(is_superuser=True)
3331 surfer, surfer_token = generate_user()
3332 host, _ = generate_user()
3334 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3335 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3336 with requests_session(surfer_token) as api:
3337 hr_id = api.CreateHostRequest(
3338 requests_pb2.CreateHostRequestReq(
3339 host_user_id=host.id,
3340 from_date=today_plus_2,
3341 to_date=today_plus_3,
3342 text=valid_request_text(),
3343 )
3344 ).host_request_id
3346 with real_moderation_session(super_token) as api:
3347 res = api.SetUserContentVisibility(
3348 moderation_pb2.SetUserContentVisibilityReq(
3349 user_id=surfer.id,
3350 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3351 from_visibility=[],
3352 )
3353 )
3354 assert res.updated_count == 1
3356 with session_scope() as session:
3357 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3358 assert state.visibility == ModerationVisibility.hidden
3361def test_SetUserContentVisibility_from_visibility_unspecified_rejected(db):
3362 super_user, super_token = generate_user(is_superuser=True)
3363 target, _ = generate_user()
3365 with real_moderation_session(super_token) as api:
3366 with pytest.raises(grpc.RpcError) as e:
3367 api.SetUserContentVisibility(
3368 moderation_pb2.SetUserContentVisibilityReq(
3369 user_id=target.id,
3370 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3371 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED],
3372 )
3373 )
3374 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
3377def test_ListModerationStates_empty(db):
3378 super_user, super_token = generate_user(is_superuser=True)
3380 with real_moderation_session(super_token) as api:
3381 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq())
3382 assert len(res.moderation_states) == 0
3383 assert res.next_page_token == ""
3386def test_ListModerationStates_returns_states_chronologically(db):
3387 super_user, super_token = generate_user(is_superuser=True)
3388 surfer, surfer_token = generate_user()
3389 host, _ = generate_user()
3391 state1_id = create_test_host_request_with_moderation(surfer_token, host.id)
3392 state2_id = create_test_host_request_with_moderation(surfer_token, host.id)
3393 state3_id = create_test_host_request_with_moderation(surfer_token, host.id)
3395 with real_moderation_session(super_token) as api:
3396 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq())
3397 assert [s.moderation_state_id for s in res.moderation_states] == [state1_id, state2_id, state3_id]
3399 res_newest = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(newest_first=True))
3400 assert [s.moderation_state_id for s in res_newest.moderation_states] == [state3_id, state2_id, state1_id]
3403def test_ListModerationStates_filter_by_author(db):
3404 super_user, super_token = generate_user(is_superuser=True)
3405 surfer1, surfer1_token = generate_user()
3406 surfer2, surfer2_token = generate_user()
3407 host, _ = generate_user()
3409 state1_id = create_test_host_request_with_moderation(surfer1_token, host.id)
3410 state2_id = create_test_host_request_with_moderation(surfer2_token, host.id)
3411 state3_id = create_test_host_request_with_moderation(surfer1_token, host.id)
3413 with real_moderation_session(super_token) as api:
3414 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer1.id))
3415 assert {s.moderation_state_id for s in res.moderation_states} == {state1_id, state3_id}
3417 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer2.id))
3418 assert [s.moderation_state_id for s in res.moderation_states] == [state2_id]
3421def test_ListModerationStates_pagination(db):
3422 super_user, super_token = generate_user(is_superuser=True)
3423 surfer, surfer_token = generate_user()
3424 host, _ = generate_user()
3426 state_ids = [create_test_host_request_with_moderation(surfer_token, host.id) for _ in range(3)]
3428 with real_moderation_session(super_token) as api:
3429 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(page_size=2))
3430 assert [s.moderation_state_id for s in res.moderation_states] == state_ids[:2]
3431 assert res.next_page_token != ""
3433 res2 = api.ListModerationStates(
3434 moderation_pb2.ListModerationStatesReq(page_size=2, page_token=res.next_page_token)
3435 )
3436 assert [s.moderation_state_id for s in res2.moderation_states] == [state_ids[2]]
3437 assert res2.next_page_token == ""