Coverage for app / backend / src / tests / test_moderation.py: 100%
1180 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
1"""
2Comprehensive tests for the Unified Moderation System (UMS)
3"""
5from datetime import datetime, timedelta
7import grpc
8import pytest
9from google.protobuf import empty_pb2
10from sqlalchemy.sql import select
12from couchers.config import config
13from couchers.db import session_scope
14from couchers.jobs.handlers import auto_approve_moderation_queue
15from couchers.jobs.worker import process_job
16from couchers.models import (
17 AdminAction,
18 EventOccurrence,
19 FriendRelationship,
20 GroupChat,
21 HostRequest,
22 ModerationAction,
23 ModerationLog,
24 ModerationObjectType,
25 ModerationQueueItem,
26 ModerationState,
27 ModerationTrigger,
28 ModerationVisibility,
29)
30from couchers.moderation.utils import create_moderation
31from couchers.proto import api_pb2, conversations_pb2, events_pb2, moderation_pb2, notifications_pb2, requests_pb2
32from couchers.utils import Timestamp_from_datetime, now, today
33from tests.fixtures.db import generate_user, make_friends
34from tests.fixtures.misc import PushCollector, mock_notification_email, process_jobs
35from tests.fixtures.sessions import (
36 api_session,
37 conversations_session,
38 events_session,
39 notifications_session,
40 real_moderation_session,
41 requests_session,
42)
43from tests.test_communities import create_community
44from tests.test_requests import valid_request_text
47@pytest.fixture(autouse=True)
48def _(testconfig):
49 pass
52def create_test_host_request_with_moderation(surfer_token, host_user_id):
53 """Helper to create a host request and return its moderation state ID"""
54 today_plus_2 = (today() + timedelta(days=2)).isoformat()
55 today_plus_3 = (today() + timedelta(days=3)).isoformat()
57 with requests_session(surfer_token) as api:
58 hr_id = api.CreateHostRequest(
59 requests_pb2.CreateHostRequestReq(
60 host_user_id=host_user_id,
61 from_date=today_plus_2,
62 to_date=today_plus_3,
63 text=valid_request_text(),
64 )
65 ).host_request_id
67 with session_scope() as session:
68 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
69 return hr.moderation_state_id
72# ============================================================================
73# Tests for moderation helper functions
74# ============================================================================
77def test_create_moderation(db):
78 """Test creating a moderation state with associated log entry"""
79 user, _ = generate_user()
81 with session_scope() as session:
82 # Create a moderation state
83 moderation_state = create_moderation(
84 session=session,
85 object_type=ModerationObjectType.host_request,
86 object_id=123,
87 creator_user_id=user.id,
88 )
90 assert moderation_state.object_type == ModerationObjectType.host_request
91 assert moderation_state.object_id == 123
92 assert moderation_state.visibility == ModerationVisibility.shadowed
94 # Check that log entry was created
95 log_entries = (
96 session.execute(select(ModerationLog).where(ModerationLog.moderation_state_id == moderation_state.id))
97 .scalars()
98 .all()
99 )
101 assert len(log_entries) == 1
102 assert log_entries[0].action == ModerationAction.create
103 assert log_entries[0].reason == "Object created."
104 assert log_entries[0].moderator_user_id == user.id
107def test_add_to_moderation_queue(db):
108 """Test adding content to moderation queue via API"""
109 super_user, super_token = generate_user(is_superuser=True)
110 user1, token1 = generate_user()
111 user2, _ = generate_user()
113 today_plus_2 = (today() + timedelta(days=2)).isoformat()
114 today_plus_3 = (today() + timedelta(days=3)).isoformat()
116 # Create a real host request (which automatically creates moderation state and adds to queue)
117 with requests_session(token1) as api:
118 host_request_id = api.CreateHostRequest(
119 requests_pb2.CreateHostRequestReq(
120 host_user_id=user2.id,
121 from_date=today_plus_2,
122 to_date=today_plus_3,
123 text=valid_request_text(),
124 )
125 ).host_request_id
127 # Get the moderation state ID
128 state_id = None
129 with session_scope() as session:
130 host_request = session.execute(
131 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
132 ).scalar_one()
133 state_id = host_request.moderation_state_id
135 # Add another item to moderation queue via API (the first one was created automatically)
136 with real_moderation_session(super_token) as api:
137 res = api.FlagContentForReview(
138 moderation_pb2.FlagContentForReviewReq(
139 moderation_state_id=state_id,
140 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
141 reason="Admin manually flagged for additional review",
142 )
143 )
145 assert res.queue_item.moderation_state_id == state_id
146 assert res.queue_item.trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG
147 assert res.queue_item.reason == "Admin manually flagged for additional review"
148 assert res.queue_item.moderation_state.author_user_id == user1.id
149 assert res.queue_item.is_resolved == False
152def test_moderate_content(db):
153 """Test moderating content via API"""
154 super_user, super_token = generate_user(is_superuser=True)
155 user, token = generate_user()
156 host, _ = generate_user()
158 today_plus_2 = (today() + timedelta(days=2)).isoformat()
159 today_plus_3 = (today() + timedelta(days=3)).isoformat()
161 # Create a real host request
162 state_id = None
163 with requests_session(token) as api:
164 hr_id = api.CreateHostRequest(
165 requests_pb2.CreateHostRequestReq(
166 host_user_id=host.id,
167 from_date=today_plus_2,
168 to_date=today_plus_3,
169 text=valid_request_text(),
170 )
171 ).host_request_id
173 with session_scope() as session:
174 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
175 state_id = hr.moderation_state_id
177 # Moderate the content via API
178 with real_moderation_session(super_token) as api:
179 res = api.ModerateContent(
180 moderation_pb2.ModerateContentReq(
181 moderation_state_id=state_id,
182 action=moderation_pb2.MODERATION_ACTION_APPROVE,
183 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
184 reason="Content looks good",
185 )
186 )
188 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
190 # Check that state was updated in database
191 with session_scope() as session:
192 updated_state = session.get_one(ModerationState, state_id)
193 assert updated_state.visibility == ModerationVisibility.visible
195 # Check that log entry was created
196 log_entries = (
197 session.execute(
198 select(ModerationLog)
199 .where(ModerationLog.moderation_state_id == state_id)
200 .order_by(ModerationLog.time.desc(), ModerationLog.id.desc())
201 )
202 .scalars()
203 .all()
204 )
206 assert len(log_entries) == 2 # CREATE + APPROVE
207 assert log_entries[0].action == ModerationAction.approve
208 assert log_entries[0].moderator_user_id == super_user.id
209 assert log_entries[0].reason == "Content looks good"
212def test_resolve_queue_item(db):
213 """Test resolving a moderation queue item via ModerateContent API"""
214 user1, token1 = generate_user()
215 user2, _ = generate_user()
216 moderator, moderator_token = generate_user(is_superuser=True)
218 today_plus_2 = (today() + timedelta(days=2)).isoformat()
219 today_plus_3 = (today() + timedelta(days=3)).isoformat()
221 # Create a host request using the API (which automatically creates moderation state)
222 with requests_session(token1) as api:
223 host_request_id = api.CreateHostRequest(
224 requests_pb2.CreateHostRequestReq(
225 host_user_id=user2.id,
226 from_date=today_plus_2,
227 to_date=today_plus_3,
228 text=valid_request_text(),
229 )
230 ).host_request_id
232 state_id = None
233 with session_scope() as session:
234 # Get the host request and its moderation state
235 host_request = session.execute(
236 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
237 ).scalar_one()
238 state_id = host_request.moderation_state_id
240 # The moderation state should already exist and be in the queue
241 queue_item = session.execute(
242 select(ModerationQueueItem)
243 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
244 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
245 ).scalar_one()
247 assert queue_item.resolved_by_log_id is None
249 # Approve content via API (which should resolve the queue item)
250 with real_moderation_session(moderator_token) as api:
251 api.ModerateContent(
252 moderation_pb2.ModerateContentReq(
253 moderation_state_id=state_id,
254 action=moderation_pb2.MODERATION_ACTION_APPROVE,
255 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
256 reason="Approved after review",
257 )
258 )
260 # Check that queue item was resolved
261 with session_scope() as session:
262 queue_item = session.execute(
263 select(ModerationQueueItem)
264 .where(ModerationQueueItem.moderation_state_id == state_id)
265 .where(ModerationQueueItem.resolved_by_log_id.is_not(None))
266 ).scalar_one()
267 assert queue_item.resolved_by_log_id is not None
270def test_approve_content_via_api(db):
271 """Test approving content via ModerateContent API"""
272 user1, token1 = generate_user()
273 user2, _ = generate_user()
274 moderator, moderator_token = generate_user(is_superuser=True)
276 today_plus_2 = (today() + timedelta(days=2)).isoformat()
277 today_plus_3 = (today() + timedelta(days=3)).isoformat()
279 # Create a host request using the API (which automatically creates moderation state)
280 with requests_session(token1) as api:
281 host_request_id = api.CreateHostRequest(
282 requests_pb2.CreateHostRequestReq(
283 host_user_id=user2.id,
284 from_date=today_plus_2,
285 to_date=today_plus_3,
286 text=valid_request_text(),
287 )
288 ).host_request_id
290 state_id = None
291 with session_scope() as session:
292 # Get the host request and its moderation state
293 host_request = session.execute(
294 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
295 ).scalar_one()
296 state_id = host_request.moderation_state_id
298 # Approve via API
299 with real_moderation_session(moderator_token) as api:
300 api.ModerateContent(
301 moderation_pb2.ModerateContentReq(
302 moderation_state_id=state_id,
303 action=moderation_pb2.MODERATION_ACTION_APPROVE,
304 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
305 reason="Quick approval",
306 )
307 )
309 # Check that state was updated to VISIBLE
310 with session_scope() as session:
311 updated_state = session.get_one(ModerationState, state_id)
312 assert updated_state.visibility == ModerationVisibility.visible
314 # Check log entry
315 log_entry = session.execute(
316 select(ModerationLog)
317 .where(ModerationLog.moderation_state_id == state_id)
318 .where(ModerationLog.action == ModerationAction.approve)
319 ).scalar_one()
321 assert log_entry.moderator_user_id == moderator.id
322 assert log_entry.reason == "Quick approval"
325# ============================================================================
326# Tests for host request moderation integration
327# ============================================================================
330def test_create_host_request_creates_moderation_state(db):
331 """Test that creating a host request automatically creates a moderation state"""
332 user1, token1 = generate_user()
333 user2, token2 = generate_user()
335 today_plus_2 = (today() + timedelta(days=2)).isoformat()
336 today_plus_3 = (today() + timedelta(days=3)).isoformat()
338 with requests_session(token1) as api:
339 host_request_id = api.CreateHostRequest(
340 requests_pb2.CreateHostRequestReq(
341 host_user_id=user2.id,
342 from_date=today_plus_2,
343 to_date=today_plus_3,
344 text=valid_request_text(),
345 )
346 ).host_request_id
348 with session_scope() as session:
349 # Check that host request has a moderation state
350 host_request = session.execute(
351 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
352 ).scalar_one()
354 # Check moderation state properties
355 moderation_state = session.execute(
356 select(ModerationState).where(ModerationState.id == host_request.moderation_state_id)
357 ).scalar_one()
359 assert moderation_state.object_type == ModerationObjectType.host_request
360 assert moderation_state.object_id == host_request_id
361 assert moderation_state.visibility == ModerationVisibility.shadowed
363 # Check that it was added to moderation queue
364 queue_items = (
365 session.execute(
366 select(ModerationQueueItem)
367 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
368 .where(ModerationQueueItem.resolved_by_log_id == None)
369 )
370 .scalars()
371 .all()
372 )
374 assert len(queue_items) == 1
375 assert queue_items[0].trigger == ModerationTrigger.initial_review
376 # item_author_user_id is no longer stored in the model, it's dynamically retrieved
379def test_host_request_no_notification_before_approval(db, push_collector: PushCollector):
380 """Test that host requests don't send notifications until approved"""
381 user1, token1 = generate_user()
382 user2, token2 = generate_user()
384 today_plus_2 = (today() + timedelta(days=2)).isoformat()
385 today_plus_3 = (today() + timedelta(days=3)).isoformat()
387 with requests_session(token1) as api:
388 host_request_id = api.CreateHostRequest(
389 requests_pb2.CreateHostRequestReq(
390 host_user_id=user2.id,
391 from_date=today_plus_2,
392 to_date=today_plus_3,
393 text=valid_request_text(),
394 )
395 ).host_request_id
397 # Process all jobs (including the notification job)
398 process_jobs()
400 # No push notification should be sent yet (host requests are shadowed initially)
401 assert push_collector.count_for_user(user2.id) == 0
404def test_shadowed_notification_not_in_list_notifications(db):
405 """Test that notifications for shadowed content don't appear in ListNotifications API"""
406 user1, token1 = generate_user()
407 user2, token2 = generate_user()
409 today_plus_2 = (today() + timedelta(days=2)).isoformat()
410 today_plus_3 = (today() + timedelta(days=3)).isoformat()
412 # Create a host request (which creates a shadowed notification for the host)
413 with requests_session(token1) as api:
414 host_request_id = api.CreateHostRequest(
415 requests_pb2.CreateHostRequestReq(
416 host_user_id=user2.id,
417 from_date=today_plus_2,
418 to_date=today_plus_3,
419 text=valid_request_text(),
420 )
421 ).host_request_id
423 # Host (recipient) should NOT see the notification in ListNotifications - it's for shadowed content
424 with notifications_session(token2) as api:
425 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
426 # Should be empty - the host request is still shadowed
427 assert len(res.notifications) == 0
430def test_notification_visible_after_approval(db):
431 """Test that notifications appear in ListNotifications after content is approved"""
432 user1, token1 = generate_user()
433 user2, token2 = generate_user()
434 mod, mod_token = generate_user(is_superuser=True)
436 today_plus_2 = (today() + timedelta(days=2)).isoformat()
437 today_plus_3 = (today() + timedelta(days=3)).isoformat()
439 # Create a host request (which creates a shadowed notification for the host)
440 with requests_session(token1) as api:
441 host_request_id = api.CreateHostRequest(
442 requests_pb2.CreateHostRequestReq(
443 host_user_id=user2.id,
444 from_date=today_plus_2,
445 to_date=today_plus_3,
446 text=valid_request_text(),
447 )
448 ).host_request_id
450 # Host (recipient) should NOT see the notification initially
451 with notifications_session(token2) as api:
452 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
453 assert len(res.notifications) == 0
455 # Get the moderation state ID and approve
456 with session_scope() as session:
457 host_request = session.execute(
458 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
459 ).scalar_one()
460 state_id = host_request.moderation_state_id
462 with real_moderation_session(mod_token) as api:
463 api.ModerateContent(
464 moderation_pb2.ModerateContentReq(
465 moderation_state_id=state_id,
466 action=moderation_pb2.MODERATION_ACTION_APPROVE,
467 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
468 reason="Looks good",
469 )
470 )
472 # Now host SHOULD see the notification
473 with notifications_session(token2) as api:
474 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
475 assert len(res.notifications) == 1
476 assert res.notifications[0].topic == "host_request"
477 assert res.notifications[0].action == "create"
480def test_shadowed_host_request_visible_to_author_only(db):
481 """Test that SHADOWED host requests are visible only to the author (surfer), not the recipient (host)"""
482 user1, token1 = generate_user()
483 user2, token2 = generate_user()
485 today_plus_2 = (today() + timedelta(days=2)).isoformat()
486 today_plus_3 = (today() + timedelta(days=3)).isoformat()
488 with requests_session(token1) as api:
489 host_request_id = api.CreateHostRequest(
490 requests_pb2.CreateHostRequestReq(
491 host_user_id=user2.id,
492 from_date=today_plus_2,
493 to_date=today_plus_3,
494 text=valid_request_text(),
495 )
496 ).host_request_id
498 # Surfer (author) can see it with GetHostRequest
499 with requests_session(token1) as api:
500 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
501 assert res.host_request_id == host_request_id
502 assert res.latest_message.text.text == valid_request_text()
504 # Host (recipient) CANNOT see it with GetHostRequest - it's shadowed
505 with requests_session(token2) as api:
506 with pytest.raises(grpc.RpcError) as e:
507 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
508 assert e.value.code() == grpc.StatusCode.NOT_FOUND
511def test_unlisted_host_request_not_in_lists(db):
512 """Test that SHADOWED host requests are visible to author but not to recipient"""
513 user1, token1 = generate_user()
514 user2, token2 = generate_user()
516 today_plus_2 = (today() + timedelta(days=2)).isoformat()
517 today_plus_3 = (today() + timedelta(days=3)).isoformat()
519 with requests_session(token1) as api:
520 host_request_id = api.CreateHostRequest(
521 requests_pb2.CreateHostRequestReq(
522 host_user_id=user2.id,
523 from_date=today_plus_2,
524 to_date=today_plus_3,
525 text=valid_request_text(),
526 )
527 ).host_request_id
529 # Surfer (author) should see it in their sent list even though it's SHADOWED
530 with requests_session(token1) as api:
531 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
532 assert len(res.host_requests) == 1
534 # Host should NOT see it in their received list (still SHADOWED from them)
535 with requests_session(token2) as api:
536 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
537 assert len(res.host_requests) == 0
540def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector):
541 """Test that approved host requests appear in lists and send notifications"""
542 user1, token1 = generate_user()
543 user2, token2 = generate_user()
544 mod, mod_token = generate_user(is_superuser=True)
546 today_plus_2 = (today() + timedelta(days=2)).isoformat()
547 today_plus_3 = (today() + timedelta(days=3)).isoformat()
549 with requests_session(token1) as api:
550 host_request_id = api.CreateHostRequest(
551 requests_pb2.CreateHostRequestReq(
552 host_user_id=user2.id,
553 from_date=today_plus_2,
554 to_date=today_plus_3,
555 text=valid_request_text(),
556 )
557 ).host_request_id
559 # Process the initial notification job - should be deferred (no notification sent)
560 process_jobs()
561 assert push_collector.count_for_user(user2.id) == 0
563 # Get the moderation state ID
564 state_id = None
565 with session_scope() as session:
566 host_request = session.execute(
567 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
568 ).scalar_one()
569 state_id = host_request.moderation_state_id
571 # Approve the host request via API
572 with real_moderation_session(mod_token) as api:
573 api.ModerateContent(
574 moderation_pb2.ModerateContentReq(
575 moderation_state_id=state_id,
576 action=moderation_pb2.MODERATION_ACTION_APPROVE,
577 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
578 reason="Looks good",
579 )
580 )
582 # Process the re-queued notification job - should now send notification
583 process_jobs()
585 # Now surfer SHOULD see it in their sent list
586 with requests_session(token1) as api:
587 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
588 assert len(res.host_requests) == 1
589 assert res.host_requests[0].host_request_id == host_request_id
591 # Host SHOULD see it in their received list
592 with requests_session(token2) as api:
593 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
594 assert len(res.host_requests) == 1
595 assert res.host_requests[0].host_request_id == host_request_id
597 # After approval, the host should have received a push notification
598 assert push_collector.pop_for_user(user2.id, last=True).topic_action == "host_request:create"
601def test_hidden_host_request_invisible_to_all(db):
602 """Test that HIDDEN host requests are invisible to everyone except moderators"""
603 user1, token1 = generate_user()
604 user2, token2 = generate_user()
605 user3, token3 = generate_user() # Third party
606 moderator, moderator_token = generate_user(is_superuser=True)
608 today_plus_2 = (today() + timedelta(days=2)).isoformat()
609 today_plus_3 = (today() + timedelta(days=3)).isoformat()
611 with requests_session(token1) as api:
612 host_request_id = api.CreateHostRequest(
613 requests_pb2.CreateHostRequestReq(
614 host_user_id=user2.id,
615 from_date=today_plus_2,
616 to_date=today_plus_3,
617 text=valid_request_text(),
618 )
619 ).host_request_id
621 # Get the moderation state ID
622 state_id = None
623 with session_scope() as session:
624 host_request = session.execute(
625 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
626 ).scalar_one()
627 state_id = host_request.moderation_state_id
629 # Hide the host request via API (e.g., spam/abuse)
630 with real_moderation_session(moderator_token) as api:
631 api.ModerateContent(
632 moderation_pb2.ModerateContentReq(
633 moderation_state_id=state_id,
634 action=moderation_pb2.MODERATION_ACTION_HIDE,
635 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
636 reason="Spam content",
637 )
638 )
640 # Surfer can't see it with GetHostRequest
641 with requests_session(token1) as api:
642 with pytest.raises(grpc.RpcError) as e:
643 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
644 assert e.value.code() == grpc.StatusCode.NOT_FOUND
646 # Host can't see it with GetHostRequest
647 with requests_session(token2) as api:
648 with pytest.raises(grpc.RpcError) as e:
649 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
650 assert e.value.code() == grpc.StatusCode.NOT_FOUND
652 # Third party definitely can't see it
653 with requests_session(token3) as api:
654 with pytest.raises(grpc.RpcError) as e:
655 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
656 assert e.value.code() == grpc.StatusCode.NOT_FOUND
658 # Not in any lists
659 with requests_session(token1) as api:
660 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
661 assert len(res.host_requests) == 0
663 with requests_session(token2) as api:
664 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
665 assert len(res.host_requests) == 0
668def test_multiple_host_requests_listing_visibility(db):
669 """Test that ListHostRequests correctly filters based on moderation state"""
670 user1, token1 = generate_user()
671 user2, token2 = generate_user()
672 moderator, moderator_token = generate_user(is_superuser=True)
674 today_plus_2 = (today() + timedelta(days=2)).isoformat()
675 today_plus_3 = (today() + timedelta(days=3)).isoformat()
677 # Create 3 host requests
678 host_request_ids = []
679 state_ids = []
680 with requests_session(token1) as api:
681 for i in range(3):
682 hr_id = api.CreateHostRequest(
683 requests_pb2.CreateHostRequestReq(
684 host_user_id=user2.id,
685 from_date=today_plus_2,
686 to_date=today_plus_3,
687 text=valid_request_text(f"Test request {i + 1}"),
688 )
689 ).host_request_id
690 host_request_ids.append(hr_id)
692 # Get state IDs
693 with session_scope() as session:
694 for hr_id in host_request_ids:
695 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
696 state_ids.append(host_request.moderation_state_id)
698 # Approve the first one via API
699 with real_moderation_session(moderator_token) as api:
700 api.ModerateContent(
701 moderation_pb2.ModerateContentReq(
702 moderation_state_id=state_ids[0],
703 action=moderation_pb2.MODERATION_ACTION_APPROVE,
704 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
705 reason="Approved",
706 )
707 )
709 # Hide the third one via API
710 with real_moderation_session(moderator_token) as api:
711 api.ModerateContent(
712 moderation_pb2.ModerateContentReq(
713 moderation_state_id=state_ids[2],
714 action=moderation_pb2.MODERATION_ACTION_HIDE,
715 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
716 reason="Spam",
717 )
718 )
720 # Surfer should see the approved one and the shadowed one (author can see their SHADOWED content)
721 with requests_session(token1) as api:
722 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
723 assert len(res.host_requests) == 2
724 visible_ids = {hr.host_request_id for hr in res.host_requests}
725 assert visible_ids == {host_request_ids[0], host_request_ids[1]}
727 # Host should see only the approved one in received list
728 with requests_session(token2) as api:
729 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
730 assert len(res.host_requests) == 1
731 assert res.host_requests[0].host_request_id == host_request_ids[0]
734def test_moderation_log_tracking(db):
735 """Test that moderation actions are properly logged via API"""
736 user, user_token = generate_user()
737 host, _ = generate_user()
738 moderator1, moderator1_token = generate_user(is_superuser=True)
739 moderator2, moderator2_token = generate_user(is_superuser=True)
741 # Create a real host request
742 state_id = create_test_host_request_with_moderation(user_token, host.id)
744 # Perform several moderation actions via API
745 with real_moderation_session(moderator1_token) as api:
746 api.ModerateContent(
747 moderation_pb2.ModerateContentReq(
748 moderation_state_id=state_id,
749 action=moderation_pb2.MODERATION_ACTION_APPROVE,
750 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
751 reason="Looks good initially",
752 )
753 )
755 with real_moderation_session(moderator2_token) as api:
756 api.FlagContentForReview(
757 moderation_pb2.FlagContentForReviewReq(
758 moderation_state_id=state_id,
759 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
760 reason="Wait, this needs another look",
761 )
762 )
763 # Shadow it back
764 api.ModerateContent(
765 moderation_pb2.ModerateContentReq(
766 moderation_state_id=state_id,
767 action=moderation_pb2.MODERATION_ACTION_HIDE,
768 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
769 reason="Wait, this needs another look",
770 )
771 )
773 with real_moderation_session(moderator1_token) as api:
774 api.ModerateContent(
775 moderation_pb2.ModerateContentReq(
776 moderation_state_id=state_id,
777 action=moderation_pb2.MODERATION_ACTION_HIDE,
778 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
779 reason="Actually it's spam",
780 )
781 )
783 # Check all log entries
784 with session_scope() as session:
785 log_entries = (
786 session.execute(
787 select(ModerationLog)
788 .where(ModerationLog.moderation_state_id == state_id)
789 .order_by(ModerationLog.time.asc())
790 )
791 .scalars()
792 .all()
793 )
795 # CREATE + APPROVE + HIDE + HIDE (shadowing back counts as HIDE action)
796 assert len(log_entries) >= 3
798 assert log_entries[0].action == ModerationAction.create
799 assert log_entries[0].moderator_user_id == user.id
800 assert log_entries[0].reason == "Object created."
802 assert log_entries[1].action == ModerationAction.approve
803 assert log_entries[1].moderator_user_id == moderator1.id
804 assert log_entries[1].reason == "Looks good initially"
806 # The last action should be hiding
807 assert log_entries[-1].action == ModerationAction.hide
808 assert log_entries[-1].moderator_user_id == moderator1.id
809 assert log_entries[-1].reason == "Actually it's spam"
812def test_moderation_queue_workflow(db):
813 """Test the full moderation queue workflow via API"""
814 user1, token1 = generate_user()
815 user2, _ = generate_user()
816 moderator, moderator_token = generate_user(is_superuser=True)
818 today_plus_2 = (today() + timedelta(days=2)).isoformat()
819 today_plus_3 = (today() + timedelta(days=3)).isoformat()
821 # Create a host request using the API (which automatically creates moderation state and adds to queue)
822 with requests_session(token1) as api:
823 host_request_id = api.CreateHostRequest(
824 requests_pb2.CreateHostRequestReq(
825 host_user_id=user2.id,
826 from_date=today_plus_2,
827 to_date=today_plus_3,
828 text=valid_request_text(),
829 )
830 ).host_request_id
832 state_id = None
833 queue_item_id = None
834 with session_scope() as session:
835 # Get the host request and its moderation state
836 host_request = session.execute(
837 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
838 ).scalar_one()
839 state_id = host_request.moderation_state_id
841 # The queue item should already exist (created automatically)
842 queue_item = session.execute(
843 select(ModerationQueueItem)
844 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
845 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
846 ).scalar_one()
847 queue_item_id = queue_item.id
849 # Verify it's in the queue
850 unresolved_items = (
851 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
852 .scalars()
853 .all()
854 )
856 assert len(unresolved_items) >= 1
857 assert queue_item.id in [item.id for item in unresolved_items]
859 # Moderator reviews and approves via API (which also resolves the queue item)
860 with real_moderation_session(moderator_token) as api:
861 api.ModerateContent(
862 moderation_pb2.ModerateContentReq(
863 moderation_state_id=state_id,
864 action=moderation_pb2.MODERATION_ACTION_APPROVE,
865 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
866 reason="Content approved",
867 )
868 )
870 # Verify queue item was resolved
871 with session_scope() as session:
872 # Verify it's no longer in unresolved queue
873 unresolved_items = (
874 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
875 .scalars()
876 .all()
877 )
879 assert queue_item_id not in [item.id for item in unresolved_items]
881 # Verify the queue item was linked to a log entry
882 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
883 assert queue_item.resolved_by_log_id is not None
886# ============================================================================
887# Moderation API Tests (testing the gRPC servicer)
888# ============================================================================
891def test_GetModerationQueue_empty(db):
892 """Test getting an empty moderation queue"""
893 super_user, super_token = generate_user(is_superuser=True)
895 with real_moderation_session(super_token) as api:
896 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
897 assert len(res.queue_items) == 0
898 assert res.next_page_token == ""
901def test_GetModerationQueue_with_items(db):
902 """Test getting moderation queue with items via API"""
903 super_user, super_token = generate_user(is_superuser=True)
904 normal_user, user_token = generate_user()
905 host, _ = generate_user()
907 # Create some host requests (which automatically adds them to moderation queue)
908 state1_id = create_test_host_request_with_moderation(user_token, host.id)
909 state2_id = create_test_host_request_with_moderation(user_token, host.id)
911 with real_moderation_session(super_token) as api:
912 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
913 assert len(res.queue_items) == 2
914 assert res.queue_items[0].is_resolved == False
915 assert res.queue_items[1].is_resolved == False
918def test_GetModerationQueue_filter_by_trigger(db):
919 """Test filtering moderation queue by trigger type via API"""
920 super_user, super_token = generate_user(is_superuser=True)
921 normal_user, user_token = generate_user()
922 host, _ = generate_user()
924 # Create host requests (which automatically adds them to moderation queue with INITIAL_REVIEW)
925 state1_id = create_test_host_request_with_moderation(user_token, host.id)
926 state2_id = create_test_host_request_with_moderation(user_token, host.id)
928 # Add USER_FLAG trigger to second item via API
929 with real_moderation_session(super_token) as api:
930 api.FlagContentForReview(
931 moderation_pb2.FlagContentForReviewReq(
932 moderation_state_id=state2_id,
933 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
934 reason="Reported by user",
935 )
936 )
938 # Filter by INITIAL_REVIEW (should get first item and maybe both depending on how queue works)
939 with real_moderation_session(super_token) as api:
940 res = api.GetModerationQueue(
941 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW])
942 )
943 assert len(res.queue_items) == 2 # Both have INITIAL_REVIEW triggers
944 assert all(item.trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW for item in res.queue_items)
946 # Filter by USER_FLAG (should get second item only)
947 with real_moderation_session(super_token) as api:
948 res = api.GetModerationQueue(
949 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_USER_FLAG])
950 )
951 assert len(res.queue_items) == 1
952 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG
955def test_GetModerationQueue_filter_created_before(db):
956 """Test filtering moderation queue by created_before timestamp"""
957 super_user, super_token = generate_user(is_superuser=True)
958 normal_user, user_token = generate_user()
959 host, _ = generate_user()
961 # Create host requests
962 state1_id = create_test_host_request_with_moderation(user_token, host.id)
963 state2_id = create_test_host_request_with_moderation(user_token, host.id)
965 # Backdate the first queue item
966 with session_scope() as session:
967 queue_item1 = session.execute(
968 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
969 ).scalar_one()
970 # Set it to 2 hours ago
971 queue_item1.time_created = now() - timedelta(hours=2)
973 # The second item remains at current time
975 # Filter to items created before 1 hour ago (should only get the first item)
976 cutoff_time = now() - timedelta(hours=1)
977 with real_moderation_session(super_token) as api:
978 res = api.GetModerationQueue(
979 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(cutoff_time))
980 )
981 assert len(res.queue_items) == 1
982 assert res.queue_items[0].moderation_state_id == state1_id
984 # Filter to items created before now (should get both)
985 with real_moderation_session(super_token) as api:
986 res = api.GetModerationQueue(
987 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(now() + timedelta(seconds=10)))
988 )
989 assert len(res.queue_items) == 2
991 # Filter to items created before 3 hours ago (should get none)
992 old_cutoff = now() - timedelta(hours=3)
993 with real_moderation_session(super_token) as api:
994 res = api.GetModerationQueue(
995 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(old_cutoff))
996 )
997 assert len(res.queue_items) == 0
1000def test_GetModerationQueue_filter_created_after(db):
1001 """Test filtering moderation queue by created_after timestamp"""
1002 super_user, super_token = generate_user(is_superuser=True)
1003 normal_user, user_token = generate_user()
1004 host, _ = generate_user()
1006 # Create host requests
1007 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1008 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1010 # Backdate the first queue item to 2 hours ago
1011 with session_scope() as session:
1012 queue_item1 = session.execute(
1013 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1014 ).scalar_one()
1015 queue_item1.time_created = now() - timedelta(hours=2)
1017 # The second item remains at current time
1019 # Filter to items created after 1 hour ago (should only get the second item)
1020 cutoff_time = now() - timedelta(hours=1)
1021 with real_moderation_session(super_token) as api:
1022 res = api.GetModerationQueue(
1023 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(cutoff_time))
1024 )
1025 assert len(res.queue_items) == 1
1026 assert res.queue_items[0].moderation_state_id == state2_id
1028 # Filter to items created after 3 hours ago (should get both)
1029 old_cutoff = now() - timedelta(hours=3)
1030 with real_moderation_session(super_token) as api:
1031 res = api.GetModerationQueue(
1032 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(old_cutoff))
1033 )
1034 assert len(res.queue_items) == 2
1036 # Filter to items created after now (should get none)
1037 with real_moderation_session(super_token) as api:
1038 res = api.GetModerationQueue(
1039 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(now() + timedelta(seconds=10)))
1040 )
1041 assert len(res.queue_items) == 0
1044def test_GetModerationQueue_filter_created_before_and_after(db):
1045 """Test filtering moderation queue by both created_before and created_after timestamps"""
1046 super_user, super_token = generate_user(is_superuser=True)
1047 normal_user, user_token = generate_user()
1048 host, _ = generate_user()
1050 # Create 3 host requests
1051 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1052 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1053 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1055 # Set different times: state1 = 3 hours ago, state2 = 1.5 hours ago, state3 = now
1056 with session_scope() as session:
1057 queue_item1 = session.execute(
1058 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1059 ).scalar_one()
1060 queue_item1.time_created = now() - timedelta(hours=3)
1062 queue_item2 = session.execute(
1063 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1064 ).scalar_one()
1065 queue_item2.time_created = now() - timedelta(hours=1, minutes=30)
1067 # Filter to items between 2 hours ago and 1 hour ago (should only get state2)
1068 after_cutoff = now() - timedelta(hours=2)
1069 before_cutoff = now() - timedelta(hours=1)
1070 with real_moderation_session(super_token) as api:
1071 res = api.GetModerationQueue(
1072 moderation_pb2.GetModerationQueueReq(
1073 created_after=Timestamp_from_datetime(after_cutoff),
1074 created_before=Timestamp_from_datetime(before_cutoff),
1075 )
1076 )
1077 assert len(res.queue_items) == 1
1078 assert res.queue_items[0].moderation_state_id == state2_id
1080 # Filter to items between 4 hours ago and 2.5 hours ago (should only get state1)
1081 after_cutoff = now() - timedelta(hours=4)
1082 before_cutoff = now() - timedelta(hours=2, minutes=30)
1083 with real_moderation_session(super_token) as api:
1084 res = api.GetModerationQueue(
1085 moderation_pb2.GetModerationQueueReq(
1086 created_after=Timestamp_from_datetime(after_cutoff),
1087 created_before=Timestamp_from_datetime(before_cutoff),
1088 )
1089 )
1090 assert len(res.queue_items) == 1
1091 assert res.queue_items[0].moderation_state_id == state1_id
1094def test_GetModerationQueue_filter_unresolved(db):
1095 """Test filtering moderation queue for unresolved items only via API"""
1096 super_user, super_token = generate_user(is_superuser=True)
1097 normal_user, user_token = generate_user()
1098 host, _ = generate_user()
1100 # Create 2 host requests
1101 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1102 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1104 # Resolve the first one via API (ModerateContent automatically resolves queue items)
1105 with real_moderation_session(super_token) as api:
1106 api.ModerateContent(
1107 moderation_pb2.ModerateContentReq(
1108 moderation_state_id=state1_id,
1109 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1110 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1111 reason="Approved",
1112 )
1113 )
1115 # Get all items
1116 with real_moderation_session(super_token) as api:
1117 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1118 assert len(res.queue_items) == 2
1120 # Get only unresolved items
1121 with real_moderation_session(super_token) as api:
1122 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1123 assert len(res.queue_items) == 1
1124 assert res.queue_items[0].is_resolved == False
1127def test_GetModerationQueue_filter_by_author(db):
1128 """Test filtering moderation queue by item_author_user_id"""
1129 super_user, super_token = generate_user(is_superuser=True)
1130 user1, token1 = generate_user()
1131 user2, token2 = generate_user()
1132 host_user, _ = generate_user()
1134 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1135 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1137 # Create 2 host requests by user1
1138 with requests_session(token1) as api:
1139 hr1_id = api.CreateHostRequest(
1140 requests_pb2.CreateHostRequestReq(
1141 host_user_id=host_user.id,
1142 from_date=today_plus_2,
1143 to_date=today_plus_3,
1144 text=valid_request_text(),
1145 )
1146 ).host_request_id
1148 hr2_id = api.CreateHostRequest(
1149 requests_pb2.CreateHostRequestReq(
1150 host_user_id=host_user.id,
1151 from_date=today_plus_2,
1152 to_date=today_plus_3,
1153 text=valid_request_text(),
1154 )
1155 ).host_request_id
1157 # Create 1 host request by user2
1158 with requests_session(token2) as api:
1159 hr3_id = api.CreateHostRequest(
1160 requests_pb2.CreateHostRequestReq(
1161 host_user_id=host_user.id,
1162 from_date=today_plus_2,
1163 to_date=today_plus_3,
1164 text=valid_request_text(),
1165 )
1166 ).host_request_id
1168 # Get moderation state IDs
1169 state1_id, state2_id, state3_id = None, None, None
1170 with session_scope() as session:
1171 hr1 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr1_id)).scalar_one()
1172 hr2 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr2_id)).scalar_one()
1173 hr3 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr3_id)).scalar_one()
1174 state1_id = hr1.moderation_state_id
1175 state2_id = hr2.moderation_state_id
1176 state3_id = hr3.moderation_state_id
1178 # Get all items (should be 3)
1179 with real_moderation_session(super_token) as api:
1180 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1181 assert len(res.queue_items) == 3
1183 # Filter by user1 (should get 2)
1184 with real_moderation_session(super_token) as api:
1185 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user1.id))
1186 assert len(res.queue_items) == 2
1187 assert all(item.moderation_state.author_user_id == user1.id for item in res.queue_items)
1189 # Filter by user2 (should get 1)
1190 with real_moderation_session(super_token) as api:
1191 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user2.id))
1192 assert len(res.queue_items) == 1
1193 assert res.queue_items[0].moderation_state.author_user_id == user2.id
1194 assert res.queue_items[0].moderation_state_id == state3_id
1196 # Filter by non-existent user (should get 0)
1197 with real_moderation_session(super_token) as api:
1198 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=999999))
1199 assert len(res.queue_items) == 0
1202def test_GetModerationQueue_ordering(db):
1203 """Test ordering moderation queue by oldest/newest first"""
1204 super_user, super_token = generate_user(is_superuser=True)
1205 normal_user, user_token = generate_user()
1206 host, _ = generate_user()
1208 # Create 3 host requests
1209 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1210 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1211 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1213 # Set different times: state1 = 3 hours ago, state2 = 2 hours ago, state3 = 1 hour ago
1214 with session_scope() as session:
1215 queue_item1 = session.execute(
1216 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1217 ).scalar_one()
1218 queue_item1.time_created = now() - timedelta(hours=3)
1220 queue_item2 = session.execute(
1221 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1222 ).scalar_one()
1223 queue_item2.time_created = now() - timedelta(hours=2)
1225 queue_item3 = session.execute(
1226 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state3_id)
1227 ).scalar_one()
1228 queue_item3.time_created = now() - timedelta(hours=1)
1230 # Default order (oldest first)
1231 with real_moderation_session(super_token) as api:
1232 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1233 assert len(res.queue_items) == 3
1234 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1235 assert res.queue_items[1].moderation_state_id == state2_id
1236 assert res.queue_items[2].moderation_state_id == state3_id # newest
1238 # Explicit oldest first
1239 with real_moderation_session(super_token) as api:
1240 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=False))
1241 assert len(res.queue_items) == 3
1242 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1243 assert res.queue_items[2].moderation_state_id == state3_id # newest
1245 # Newest first
1246 with real_moderation_session(super_token) as api:
1247 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=True))
1248 assert len(res.queue_items) == 3
1249 assert res.queue_items[0].moderation_state_id == state3_id # newest
1250 assert res.queue_items[1].moderation_state_id == state2_id
1251 assert res.queue_items[2].moderation_state_id == state1_id # oldest
1254def test_GetModerationQueue_pagination_newest_first(db):
1255 """Test pagination with newest_first=True returns different items on each page"""
1256 super_user, super_token = generate_user(is_superuser=True)
1257 normal_user, normal_token = generate_user()
1258 host_user, _ = generate_user()
1260 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1261 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1263 # Create 5 host requests
1264 hr_ids = []
1265 with requests_session(normal_token) as api:
1266 for i in range(5):
1267 hr_id = api.CreateHostRequest(
1268 requests_pb2.CreateHostRequestReq(
1269 host_user_id=host_user.id,
1270 from_date=today_plus_2,
1271 to_date=today_plus_3,
1272 text=valid_request_text(),
1273 )
1274 ).host_request_id
1275 hr_ids.append(hr_id)
1277 # Get moderation state IDs
1278 state_ids = []
1279 with session_scope() as session:
1280 for hr_id in hr_ids:
1281 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
1282 state_ids.append(hr.moderation_state_id)
1284 # Set different times so ordering is deterministic
1285 with session_scope() as session:
1286 for i, state_id in enumerate(state_ids):
1287 queue_item = session.execute(
1288 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
1289 ).scalar_one()
1290 queue_item.time_created = now() - timedelta(hours=5 - i) # oldest first in list
1292 # Get first page (2 items) with newest_first=True, filtered to our user's items
1293 with real_moderation_session(super_token) as api:
1294 res1 = api.GetModerationQueue(
1295 moderation_pb2.GetModerationQueueReq(page_size=2, newest_first=True, item_author_user_id=normal_user.id)
1296 )
1297 assert len(res1.queue_items) == 2
1298 # Should get newest items: state_ids[4], state_ids[3]
1299 assert res1.queue_items[0].moderation_state_id == state_ids[4]
1300 assert res1.queue_items[1].moderation_state_id == state_ids[3]
1301 assert res1.next_page_token # should have more pages
1303 # Get second page using the token
1304 res2 = api.GetModerationQueue(
1305 moderation_pb2.GetModerationQueueReq(
1306 page_size=2, newest_first=True, page_token=res1.next_page_token, item_author_user_id=normal_user.id
1307 )
1308 )
1309 assert len(res2.queue_items) == 2
1310 # Should get next newest items: state_ids[2], state_ids[1]
1311 assert res2.queue_items[0].moderation_state_id == state_ids[2]
1312 assert res2.queue_items[1].moderation_state_id == state_ids[1]
1314 # Pages should not overlap
1315 page1_ids = {item.moderation_state_id for item in res1.queue_items}
1316 page2_ids = {item.moderation_state_id for item in res2.queue_items}
1317 assert page1_ids.isdisjoint(page2_ids), "Pages should not have overlapping items"
1320def test_GetModerationLog(db):
1321 """Test getting moderation log for a state via API"""
1322 super_user, super_token = generate_user(is_superuser=True)
1323 moderator, moderator_token = generate_user(is_superuser=True)
1324 normal_user, user_token = generate_user()
1325 host, _ = generate_user()
1327 # Create a real host request
1328 state_id = create_test_host_request_with_moderation(user_token, host.id)
1330 # Perform a moderation action via API
1331 with real_moderation_session(moderator_token) as api:
1332 api.ModerateContent(
1333 moderation_pb2.ModerateContentReq(
1334 moderation_state_id=state_id,
1335 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1336 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1337 reason="Looks good",
1338 )
1339 )
1341 with real_moderation_session(super_token) as api:
1342 res = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
1343 assert len(res.log_entries) == 2 # CREATE + APPROVE
1344 assert res.moderation_state.moderation_state_id == state_id
1345 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1346 # Log entries are in reverse chronological order
1347 assert res.log_entries[0].action == moderation_pb2.MODERATION_ACTION_APPROVE
1348 assert res.log_entries[0].moderator_user_id == moderator.id
1349 assert res.log_entries[0].reason == "Looks good"
1350 assert res.log_entries[1].action == moderation_pb2.MODERATION_ACTION_CREATE
1351 assert res.log_entries[1].moderator_user_id == normal_user.id
1354def test_GetModerationLog_not_found(db):
1355 """Test getting moderation log for non-existent state"""
1356 super_user, super_token = generate_user(is_superuser=True)
1358 with real_moderation_session(super_token) as api:
1359 with pytest.raises(grpc.RpcError) as e:
1360 api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=999999))
1361 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1362 assert e.value.details() == "Moderation state not found."
1365def test_GetModerationState(db):
1366 """Test getting moderation state by object type and ID"""
1367 super_user, super_token = generate_user(is_superuser=True)
1368 user1, token1 = generate_user()
1369 user2, _ = generate_user()
1371 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1372 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1374 with requests_session(token1) as api:
1375 host_request_id = api.CreateHostRequest(
1376 requests_pb2.CreateHostRequestReq(
1377 host_user_id=user2.id,
1378 from_date=today_plus_2,
1379 to_date=today_plus_3,
1380 text=valid_request_text(),
1381 )
1382 ).host_request_id
1384 with real_moderation_session(super_token) as api:
1385 res = api.GetModerationState(
1386 moderation_pb2.GetModerationStateReq(
1387 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1388 object_id=host_request_id,
1389 )
1390 )
1391 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST
1392 assert res.moderation_state.object_id == host_request_id
1393 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1394 assert res.moderation_state.moderation_state_id > 0
1397def test_GetModerationState_not_found(db):
1398 """Test getting moderation state for non-existent object"""
1399 super_user, super_token = generate_user(is_superuser=True)
1401 with real_moderation_session(super_token) as api:
1402 with pytest.raises(grpc.RpcError) as e:
1403 api.GetModerationState(
1404 moderation_pb2.GetModerationStateReq(
1405 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1406 object_id=999999,
1407 )
1408 )
1409 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1410 assert e.value.details() == "Moderation state not found."
1413def test_GetModerationState_unspecified_type(db):
1414 """Test getting moderation state with unspecified object type"""
1415 super_user, super_token = generate_user(is_superuser=True)
1417 with real_moderation_session(super_token) as api:
1418 with pytest.raises(grpc.RpcError) as e:
1419 api.GetModerationState(
1420 moderation_pb2.GetModerationStateReq(
1421 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED,
1422 object_id=123,
1423 )
1424 )
1425 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1426 assert e.value.details() == "Object type must be specified."
1429def test_ModerateContent_approve(db):
1430 """Test approving content via unified moderation API"""
1431 super_user, super_token = generate_user(is_superuser=True)
1432 user1, token1 = generate_user()
1433 user2, _ = generate_user()
1435 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1436 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1438 # Create a host request using the API (which automatically creates moderation state)
1439 with requests_session(token1) as api:
1440 host_request_id = api.CreateHostRequest(
1441 requests_pb2.CreateHostRequestReq(
1442 host_user_id=user2.id,
1443 from_date=today_plus_2,
1444 to_date=today_plus_3,
1445 text=valid_request_text(),
1446 )
1447 ).host_request_id
1449 # Get the moderation state ID
1450 state_id = None
1451 with session_scope() as session:
1452 host_request = session.execute(
1453 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1454 ).scalar_one()
1455 state_id = host_request.moderation_state_id
1457 with real_moderation_session(super_token) as api:
1458 res = api.ModerateContent(
1459 moderation_pb2.ModerateContentReq(
1460 moderation_state_id=state_id,
1461 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1462 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1463 reason="Approved by admin",
1464 )
1465 )
1466 assert res.moderation_state.moderation_state_id == state_id
1467 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1469 # Verify state was updated in database
1470 with session_scope() as session:
1471 state = session.get_one(ModerationState, state_id)
1472 assert state.visibility == ModerationVisibility.visible
1475def test_ModerateContent_not_found(db):
1476 """Test moderating non-existent content"""
1477 super_user, super_token = generate_user(is_superuser=True)
1479 with real_moderation_session(super_token) as api:
1480 with pytest.raises(grpc.RpcError) as e:
1481 api.ModerateContent(
1482 moderation_pb2.ModerateContentReq(
1483 moderation_state_id=999999,
1484 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1485 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1486 reason="Test",
1487 )
1488 )
1489 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1490 assert e.value.details() == "Moderation state not found."
1493def test_ModerateContent_hide(db):
1494 """Test hiding content via unified moderation API"""
1495 super_user, super_token = generate_user(is_superuser=True)
1496 normal_user, user_token = generate_user()
1497 host, _ = generate_user()
1499 # Create a real host request
1500 state_id = create_test_host_request_with_moderation(user_token, host.id)
1502 with real_moderation_session(super_token) as api:
1503 res = api.ModerateContent(
1504 moderation_pb2.ModerateContentReq(
1505 moderation_state_id=state_id,
1506 action=moderation_pb2.MODERATION_ACTION_HIDE,
1507 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1508 reason="Spam content",
1509 )
1510 )
1511 assert res.moderation_state.moderation_state_id == state_id
1512 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_HIDDEN
1514 # Verify state was updated in database
1515 with session_scope() as session:
1516 state = session.get_one(ModerationState, state_id)
1517 assert state.visibility == ModerationVisibility.hidden
1520def test_ModerateContent_shadow(db):
1521 """Test shadowing 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_SHADOWED,
1535 reason="Needs further review",
1536 )
1537 )
1538 assert res.moderation_state.moderation_state_id == state_id
1539 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
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.shadowed
1547def test_FlagContentForReview(db):
1548 """Test flagging content for review via admin API"""
1549 super_user, super_token = generate_user(is_superuser=True)
1550 user1, token1 = generate_user()
1551 user2, _ = generate_user()
1553 # Create a host request
1554 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1555 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1557 with requests_session(token1) as api:
1558 host_request_id = api.CreateHostRequest(
1559 requests_pb2.CreateHostRequestReq(
1560 host_user_id=user2.id,
1561 from_date=today_plus_2,
1562 to_date=today_plus_3,
1563 text=valid_request_text(),
1564 )
1565 ).host_request_id
1567 # Get the moderation state ID
1568 with session_scope() as session:
1569 host_request = session.execute(
1570 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1571 ).scalar_one()
1572 state_id = host_request.moderation_state_id
1574 with real_moderation_session(super_token) as api:
1575 res = api.FlagContentForReview(
1576 moderation_pb2.FlagContentForReviewReq(
1577 moderation_state_id=state_id,
1578 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
1579 reason="Admin flagged for additional review",
1580 )
1581 )
1582 assert res.queue_item.moderation_state_id == state_id
1583 assert res.queue_item.trigger == moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW
1584 assert res.queue_item.is_resolved == False
1586 # Verify queue item was created in database
1587 with session_scope() as session:
1588 # Get the most recent queue item (the one we just created)
1589 queue_item = (
1590 session.execute(
1591 select(ModerationQueueItem)
1592 .where(ModerationQueueItem.moderation_state_id == state_id)
1593 .order_by(ModerationQueueItem.time_created.desc())
1594 )
1595 .scalars()
1596 .first()
1597 )
1598 assert queue_item
1599 assert queue_item.trigger == ModerationTrigger.moderator_review
1600 assert queue_item.resolved_by_log_id is None
1603# ============================================================================
1604# Tests for group chat moderation
1605# ============================================================================
1608def test_group_chat_created_with_moderation_state(db):
1609 """Test that group chats are created with moderation state"""
1610 user1, token1 = generate_user()
1611 user2, _ = generate_user()
1612 make_friends(user1, user2)
1614 with conversations_session(token1) as api:
1615 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1616 group_chat_id = res.group_chat_id
1618 # Verify moderation state was created
1619 with session_scope() as session:
1620 group_chat = session.execute(select(GroupChat).where(GroupChat.conversation_id == group_chat_id)).scalar_one()
1622 assert group_chat.moderation_state.object_type == ModerationObjectType.group_chat
1623 assert group_chat.moderation_state.object_id == group_chat_id
1624 # Group chats start as SHADOWED
1625 assert group_chat.moderation_state.visibility == ModerationVisibility.shadowed
1627 # A moderation queue item should have been created
1628 queue_item = (
1629 session.execute(
1630 select(ModerationQueueItem).where(
1631 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id
1632 )
1633 )
1634 .scalars()
1635 .first()
1636 )
1637 assert queue_item is not None
1638 assert queue_item.trigger == ModerationTrigger.initial_review
1641def test_group_chat_GetModerationState(db):
1642 """Test GetModerationState API for group chats"""
1643 user1, token1 = generate_user()
1644 user2, _ = generate_user()
1645 moderator, mod_token = generate_user(is_superuser=True)
1646 make_friends(user1, user2)
1648 with conversations_session(token1) as api:
1649 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1650 group_chat_id = res.group_chat_id
1652 # Moderator can look up the moderation state
1653 with real_moderation_session(mod_token) as api:
1654 res = api.GetModerationState(
1655 moderation_pb2.GetModerationStateReq(
1656 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1657 object_id=group_chat_id,
1658 )
1659 )
1660 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT
1661 assert res.moderation_state.object_id == group_chat_id
1662 # Starts as SHADOWED
1663 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1666def test_group_chat_moderation_hide(db):
1667 """Test that a moderator can hide a group chat and participants can no longer see it"""
1668 user1, token1 = generate_user()
1669 user2, token2 = generate_user()
1670 moderator, mod_token = generate_user(is_superuser=True)
1671 make_friends(user1, user2)
1673 with conversations_session(token1) as api:
1674 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1675 group_chat_id = res.group_chat_id
1676 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
1678 # First approve the group chat so both users can see it
1679 with real_moderation_session(mod_token) as api:
1680 state_res = api.GetModerationState(
1681 moderation_pb2.GetModerationStateReq(
1682 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1683 object_id=group_chat_id,
1684 )
1685 )
1686 api.ModerateContent(
1687 moderation_pb2.ModerateContentReq(
1688 moderation_state_id=state_res.moderation_state.moderation_state_id,
1689 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1690 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1691 reason="Approved",
1692 )
1693 )
1695 # Both users can see the chat now
1696 with conversations_session(token1) as api:
1697 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1698 assert len(res.group_chats) == 1
1700 with conversations_session(token2) as api:
1701 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1702 assert len(res.group_chats) == 1
1704 # Moderator hides the group chat
1705 with real_moderation_session(mod_token) as api:
1706 state_res = api.GetModerationState(
1707 moderation_pb2.GetModerationStateReq(
1708 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1709 object_id=group_chat_id,
1710 )
1711 )
1712 api.ModerateContent(
1713 moderation_pb2.ModerateContentReq(
1714 moderation_state_id=state_res.moderation_state.moderation_state_id,
1715 action=moderation_pb2.MODERATION_ACTION_HIDE,
1716 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1717 reason="Inappropriate content",
1718 )
1719 )
1721 # Neither user can see the chat now
1722 with conversations_session(token1) as api:
1723 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1724 assert len(res.group_chats) == 0
1726 with conversations_session(token2) as api:
1727 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1728 assert len(res.group_chats) == 0
1730 # Trying to get messages returns empty (chat is hidden so no messages visible)
1731 with conversations_session(token1) as api:
1732 res = api.GetGroupChatMessages(conversations_pb2.GetGroupChatMessagesReq(group_chat_id=group_chat_id))
1733 assert len(res.messages) == 0
1736def test_group_chat_moderation_shadow(db):
1737 """Test that shadowing a group chat hides it from non-creator participants"""
1738 user1, token1 = generate_user() # Creator
1739 user2, token2 = generate_user() # Participant
1740 moderator, mod_token = generate_user(is_superuser=True)
1741 make_friends(user1, user2)
1743 with conversations_session(token1) as api:
1744 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1745 group_chat_id = res.group_chat_id
1746 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
1748 # Moderator shadows the group chat
1749 with real_moderation_session(mod_token) as api:
1750 state_res = api.GetModerationState(
1751 moderation_pb2.GetModerationStateReq(
1752 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1753 object_id=group_chat_id,
1754 )
1755 )
1756 api.ModerateContent(
1757 moderation_pb2.ModerateContentReq(
1758 moderation_state_id=state_res.moderation_state.moderation_state_id,
1759 action=moderation_pb2.MODERATION_ACTION_HIDE,
1760 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
1761 reason="Needs review",
1762 )
1763 )
1765 # Creator can see SHADOWED content in list operations
1766 with conversations_session(token1) as api:
1767 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1768 assert len(res.group_chats) == 1
1769 assert res.group_chats[0].group_chat_id == group_chat_id
1771 # But non-creator participant cannot see it in lists
1772 with conversations_session(token2) as api:
1773 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1774 assert len(res.group_chats) == 0
1776 # Creator can also access it directly via GetGroupChat
1777 with conversations_session(token1) as api:
1778 res = api.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id))
1779 assert res.group_chat_id == group_chat_id
1782# ============================================================================
1783# Tests for auto-approval background job
1784# ============================================================================
1787def test_auto_approve_moderation_queue_disabled_when_zero(db):
1788 """Test that auto-approval is disabled when deadline is 0"""
1789 moderator, mod_token = generate_user(is_superuser=True)
1790 user1, token1 = generate_user()
1791 user2, token2 = generate_user()
1793 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1794 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1796 # Create a host request
1797 with requests_session(token1) as api:
1798 with mock_notification_email() as mock:
1799 host_request_id = api.CreateHostRequest(
1800 requests_pb2.CreateHostRequestReq(
1801 host_user_id=user2.id,
1802 from_date=today_plus_2,
1803 to_date=today_plus_3,
1804 text=valid_request_text(),
1805 )
1806 ).host_request_id
1808 # No email should have been sent (request is shadowed)
1809 mock.assert_not_called()
1811 # Ensure deadline is 0 (disabled)
1812 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 0
1814 # Run the job
1815 auto_approve_moderation_queue(empty_pb2.Empty())
1817 # Surfer (author) can see the request via API
1818 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1819 assert res.host_request_id == host_request_id
1821 # Author can see their SHADOWED request in their sent list
1822 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
1823 assert len(res.host_requests) == 1
1824 assert res.host_requests[0].host_request_id == host_request_id
1826 # Host cannot see the request (it's shadowed from them)
1827 with requests_session(token2) as api:
1828 with pytest.raises(grpc.RpcError) as e:
1829 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1830 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1832 # Host doesn't see it in their received list either
1833 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
1834 assert len(res.host_requests) == 0
1836 # Moderator can still see the item in the moderation queue
1837 with real_moderation_session(mod_token) as api:
1838 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1839 assert len(res.queue_items) == 1
1840 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW
1842 # Moderator can check the state is still SHADOWED
1843 state_res = api.GetModerationState(
1844 moderation_pb2.GetModerationStateReq(
1845 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1846 object_id=host_request_id,
1847 )
1848 )
1849 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1852def test_auto_approve_moderation_queue_approves_old_items(db, push_collector: PushCollector):
1853 """Test that auto-approval approves items older than the deadline"""
1854 moderator, mod_token = generate_user(is_superuser=True)
1855 user1, token1 = generate_user()
1856 user2, token2 = generate_user()
1858 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1859 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1861 # Create a host request
1862 with requests_session(token1) as api:
1863 with mock_notification_email() as mock:
1864 host_request_id = api.CreateHostRequest(
1865 requests_pb2.CreateHostRequestReq(
1866 host_user_id=user2.id,
1867 from_date=today_plus_2,
1868 to_date=today_plus_3,
1869 text=valid_request_text("Test request for auto-approval"),
1870 )
1871 ).host_request_id
1873 # No email sent initially (shadowed)
1874 mock.assert_not_called()
1876 # Host cannot see the request yet
1877 with requests_session(token2) as api:
1878 with pytest.raises(grpc.RpcError) as e:
1879 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1880 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1882 # Make the queue item appear old by backdating its time_created
1883 with session_scope() as session:
1884 host_request = session.execute(
1885 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1886 ).scalar_one()
1887 queue_item = session.execute(
1888 select(ModerationQueueItem)
1889 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
1890 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1891 ).scalar_one()
1892 # Backdate the queue item by 2 minutes
1893 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=2)
1895 # Set deadline to 60 seconds (items older than 60 seconds will be auto-approved)
1896 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 60
1897 config["MODERATION_BOT_USER_ID"] = moderator.id
1899 # Run the job
1900 auto_approve_moderation_queue(empty_pb2.Empty())
1902 # Now host can see the request via API
1903 with requests_session(token2) as api:
1904 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1905 assert res.host_request_id == host_request_id
1906 assert res.latest_message.text.text == valid_request_text("Test request for auto-approval")
1908 # Host sees it in their received list
1909 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
1910 assert len(res.host_requests) == 1
1911 assert res.host_requests[0].host_request_id == host_request_id
1913 # Surfer sees it in their sent list
1914 with requests_session(token1) as api:
1915 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
1916 assert len(res.host_requests) == 1
1917 assert res.host_requests[0].host_request_id == host_request_id
1919 # Moderator sees the queue item is now resolved
1920 with real_moderation_session(mod_token) as api:
1921 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1922 assert len(res.queue_items) == 0
1924 # State is now VISIBLE
1925 state_res = api.GetModerationState(
1926 moderation_pb2.GetModerationStateReq(
1927 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1928 object_id=host_request_id,
1929 )
1930 )
1931 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1933 # Check the log shows auto-approval by the bot user
1934 log_res = api.GetModerationLog(
1935 moderation_pb2.GetModerationLogReq(moderation_state_id=state_res.moderation_state.moderation_state_id)
1936 )
1937 # Find the APPROVE action
1938 approve_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_APPROVE]
1939 assert len(approve_entries) == 1
1940 assert "Auto-approved" in approve_entries[0].reason
1941 assert "60 seconds" in approve_entries[0].reason
1942 assert approve_entries[0].moderator_user_id == moderator.id
1945def test_auto_approve_does_not_approve_recent_items(db):
1946 """Test that auto-approval does not approve items that are newer than the deadline"""
1947 moderator, mod_token = generate_user(is_superuser=True)
1948 user1, token1 = generate_user()
1949 user2, token2 = generate_user()
1951 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1952 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1954 # Create a host request
1955 with requests_session(token1) as api:
1956 with mock_notification_email() as mock:
1957 host_request_id = api.CreateHostRequest(
1958 requests_pb2.CreateHostRequestReq(
1959 host_user_id=user2.id,
1960 from_date=today_plus_2,
1961 to_date=today_plus_3,
1962 text=valid_request_text(),
1963 )
1964 ).host_request_id
1966 # No email sent (shadowed)
1967 mock.assert_not_called()
1969 # Set deadline to 1 hour (items older than 1 hour will be auto-approved)
1970 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 3600
1971 config["MODERATION_BOT_USER_ID"] = moderator.id
1973 # Run the job - the item was just created, so it shouldn't be approved
1974 with mock_notification_email() as mock:
1975 auto_approve_moderation_queue(empty_pb2.Empty())
1977 # Still no email sent
1978 mock.assert_not_called()
1980 # Host still cannot see the request
1981 with requests_session(token2) as api:
1982 with pytest.raises(grpc.RpcError) as e:
1983 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1984 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1986 # Not in host's received list
1987 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
1988 assert len(res.host_requests) == 0
1990 # Moderator sees it still in queue unresolved
1991 with real_moderation_session(mod_token) as api:
1992 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1993 assert len(res.queue_items) == 1
1995 # State is still SHADOWED
1996 state_res = api.GetModerationState(
1997 moderation_pb2.GetModerationStateReq(
1998 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1999 object_id=host_request_id,
2000 )
2001 )
2002 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2005def test_auto_approve_does_not_approve_already_approved(db):
2006 """Test that auto-approval does not re-approve already visible content"""
2007 moderator, mod_token = generate_user(is_superuser=True)
2008 user1, token1 = generate_user()
2009 user2, token2 = generate_user()
2011 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2012 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2014 # Create a host request
2015 with requests_session(token1) as api:
2016 host_request_id = api.CreateHostRequest(
2017 requests_pb2.CreateHostRequestReq(
2018 host_user_id=user2.id,
2019 from_date=today_plus_2,
2020 to_date=today_plus_3,
2021 text=valid_request_text(),
2022 )
2023 ).host_request_id
2025 # Moderator approves it manually
2026 with real_moderation_session(mod_token) as api:
2027 state_res = api.GetModerationState(
2028 moderation_pb2.GetModerationStateReq(
2029 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2030 object_id=host_request_id,
2031 )
2032 )
2033 state_id = state_res.moderation_state.moderation_state_id
2035 api.ModerateContent(
2036 moderation_pb2.ModerateContentReq(
2037 moderation_state_id=state_id,
2038 action=moderation_pb2.MODERATION_ACTION_APPROVE,
2039 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2040 reason="Approved by moderator",
2041 )
2042 )
2044 # Host can now see it
2045 with requests_session(token2) as api:
2046 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2047 assert res.host_request_id == host_request_id
2049 # Get log count before auto-approval
2050 with real_moderation_session(mod_token) as api:
2051 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2052 log_count_before = len(log_res_before.log_entries)
2054 # Set deadline to 1 second
2055 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1
2056 config["MODERATION_BOT_USER_ID"] = moderator.id
2058 # Run the job
2059 auto_approve_moderation_queue(empty_pb2.Empty())
2061 # No new log entries should be created (already approved, queue item resolved)
2062 with real_moderation_session(mod_token) as api:
2063 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2064 assert len(log_res_after.log_entries) == log_count_before
2066 # Queue should be empty (item was resolved when moderator approved)
2067 queue_res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2068 assert len(queue_res.queue_items) == 0
2071def test_auto_approve_does_not_approve_moderator_shadowed_items(db):
2072 """Test that auto-approval does not approve items that were explicitly shadowed by a moderator"""
2073 moderator, mod_token = generate_user(is_superuser=True)
2074 user1, token1 = generate_user()
2075 user2, token2 = generate_user()
2077 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2078 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2080 # Create a host request
2081 with requests_session(token1) as api:
2082 host_request_id = api.CreateHostRequest(
2083 requests_pb2.CreateHostRequestReq(
2084 host_user_id=user2.id,
2085 from_date=today_plus_2,
2086 to_date=today_plus_3,
2087 text=valid_request_text(),
2088 )
2089 ).host_request_id
2091 # Moderator explicitly shadows the content (keeping it shadowed but resolving the queue item)
2092 with real_moderation_session(mod_token) as api:
2093 state_res = api.GetModerationState(
2094 moderation_pb2.GetModerationStateReq(
2095 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2096 object_id=host_request_id,
2097 )
2098 )
2099 state_id = state_res.moderation_state.moderation_state_id
2101 # Set to SHADOWED explicitly - this resolves the INITIAL_REVIEW queue item
2102 api.ModerateContent(
2103 moderation_pb2.ModerateContentReq(
2104 moderation_state_id=state_id,
2105 action=moderation_pb2.MODERATION_ACTION_HIDE,
2106 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2107 reason="Keeping shadowed for review",
2108 )
2109 )
2111 # Backdate to ensure it would be old enough for auto-approval
2112 with session_scope() as session:
2113 queue_item = session.execute(
2114 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
2115 ).scalar_one()
2116 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10)
2118 # Set deadline to 1 second
2119 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1
2120 config["MODERATION_BOT_USER_ID"] = moderator.id
2122 # Get log count before
2123 with real_moderation_session(mod_token) as api:
2124 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2125 log_count_before = len(log_res_before.log_entries)
2127 # Run the job
2128 auto_approve_moderation_queue(empty_pb2.Empty())
2130 # No new log entries - the queue item was resolved when moderator shadowed it
2131 with real_moderation_session(mod_token) as api:
2132 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2133 assert len(log_res_after.log_entries) == log_count_before
2135 # State should still be SHADOWED (not auto-approved to VISIBLE)
2136 state_res = api.GetModerationState(
2137 moderation_pb2.GetModerationStateReq(
2138 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2139 object_id=host_request_id,
2140 )
2141 )
2142 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2144 # Host still cannot see the request
2145 with requests_session(token2) as api:
2146 with pytest.raises(grpc.RpcError) as e:
2147 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2148 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2151# ============================================================================
2152# Notification Suppression Tests
2153# ============================================================================
2156def test_host_request_message_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2157 """
2158 Test that notifications are NOT sent for messages in host requests
2159 that haven't been approved yet.
2160 """
2161 host, host_token = generate_user(complete_profile=True)
2162 surfer, surfer_token = generate_user(complete_profile=True)
2164 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2165 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2167 # Create host request (it starts in SHADOWED state)
2168 with requests_session(surfer_token) as api:
2169 hr_id = api.CreateHostRequest(
2170 requests_pb2.CreateHostRequestReq(
2171 host_user_id=host.id,
2172 from_date=today_plus_2,
2173 to_date=today_plus_3,
2174 text=valid_request_text("Initial request message"),
2175 )
2176 ).host_request_id
2178 # No notifications should have been sent to the host (request is SHADOWED)
2179 assert push_collector.count_for_user(host.id) == 0
2181 # Send additional messages BEFORE approval - should NOT generate notifications
2182 with requests_session(surfer_token) as api:
2183 api.SendHostRequestMessage(
2184 requests_pb2.SendHostRequestMessageReq(
2185 host_request_id=hr_id,
2186 text="Follow-up message 1",
2187 )
2188 )
2189 api.SendHostRequestMessage(
2190 requests_pb2.SendHostRequestMessageReq(
2191 host_request_id=hr_id,
2192 text="Follow-up message 2",
2193 )
2194 )
2196 # Host should STILL have no notifications (messages sent while SHADOWED)
2197 assert push_collector.count_for_user(host.id) == 0
2199 # Now approve the request
2200 with mock_notification_email():
2201 moderator.approve_host_request(hr_id)
2203 # Host should now have 3 notifications (all deferred notifications are delivered on approval):
2204 # 1. host_request:create (the initial request)
2205 # 2. host_request:message (Follow-up message 1)
2206 # 3. host_request:message (Follow-up message 2)
2207 assert push_collector.count_for_user(host.id) == 3
2208 push = push_collector.pop_for_user(host.id, last=False)
2209 assert push.content.title == f"New host request from {surfer.name}"
2212def test_host_request_status_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2213 """
2214 Test that status change notifications (accept/reject/etc.) are NOT sent
2215 for host requests that haven't been approved yet.
2217 Note: In practice, the host can't even SEE the request to accept/reject it
2218 when it's SHADOWED. But if they somehow did, we still shouldn't notify.
2219 """
2220 host, host_token = generate_user(complete_profile=True)
2221 surfer, surfer_token = generate_user(complete_profile=True)
2223 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2224 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2226 # Create host request
2227 with requests_session(surfer_token) as api:
2228 hr_id = api.CreateHostRequest(
2229 requests_pb2.CreateHostRequestReq(
2230 host_user_id=host.id,
2231 from_date=today_plus_2,
2232 to_date=today_plus_3,
2233 text=valid_request_text(),
2234 )
2235 ).host_request_id
2237 # No notifications should have been sent to the host (request is SHADOWED)
2238 assert push_collector.count_for_user(host.id) == 0
2240 # The surfer can cancel their own request even when SHADOWED
2241 # But this should NOT notify the host since the request isn't approved
2242 with requests_session(surfer_token) as api:
2243 api.RespondHostRequest(
2244 requests_pb2.RespondHostRequestReq(
2245 host_request_id=hr_id,
2246 status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED,
2247 text="Actually, never mind",
2248 )
2249 )
2251 # Host should STILL have no notifications (cancel notification suppressed)
2252 assert push_collector.count_for_user(host.id) == 0
2255def test_host_request_notifications_sent_after_approval(db, push_collector: PushCollector, moderator):
2256 """
2257 Test that after a host request is approved, all notifications work normally.
2258 """
2259 host, host_token = generate_user(complete_profile=True)
2260 surfer, surfer_token = generate_user(complete_profile=True)
2262 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2263 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2265 # Create and approve host request
2266 with requests_session(surfer_token) as api:
2267 hr_id = api.CreateHostRequest(
2268 requests_pb2.CreateHostRequestReq(
2269 host_user_id=host.id,
2270 from_date=today_plus_2,
2271 to_date=today_plus_3,
2272 text=valid_request_text(),
2273 )
2274 ).host_request_id
2276 with mock_notification_email():
2277 moderator.approve_host_request(hr_id)
2279 # Host should have received 1 notification (the approval notification)
2280 push_collector.pop_for_user(host.id, last=True)
2282 # Host accepts the request - surfer should be notified
2283 with requests_session(host_token) as api:
2284 with mock_notification_email():
2285 api.RespondHostRequest(
2286 requests_pb2.RespondHostRequestReq(
2287 host_request_id=hr_id,
2288 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
2289 text="Sure, come on over!",
2290 )
2291 )
2293 # Surfer should have 1 notification (the accept notification)
2294 push = push_collector.pop_for_user(surfer.id, last=True)
2295 assert push.content.title == f"{host.name} accepted your host request"
2297 # Surfer confirms - host should be notified
2298 with requests_session(surfer_token) as api:
2299 with mock_notification_email():
2300 api.RespondHostRequest(
2301 requests_pb2.RespondHostRequestReq(
2302 host_request_id=hr_id,
2303 status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED,
2304 text="See you then!",
2305 )
2306 )
2308 # Host should now have received the confirmation notifications
2309 push = push_collector.pop_for_user(host.id, last=True)
2310 assert push.content.title == f"{surfer.name} confirmed their host request"
2313def test_group_chat_message_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2314 """
2315 Test that notifications are NOT sent for messages in group chats
2316 that haven't been approved yet.
2317 """
2318 user1, token1 = generate_user(complete_profile=True)
2319 user2, token2 = generate_user(complete_profile=True)
2321 # Create a group chat (starts in SHADOWED state)
2322 with conversations_session(token1) as api:
2323 res = api.CreateGroupChat(
2324 conversations_pb2.CreateGroupChatReq(
2325 recipient_user_ids=[user2.id],
2326 )
2327 )
2328 gc_id = res.group_chat_id
2330 # Verify initial state
2331 with session_scope() as session:
2332 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2333 assert gc.moderation_state.visibility == ModerationVisibility.shadowed
2335 # No notifications should have been sent yet (chat is SHADOWED)
2336 assert push_collector.count_for_user(user2.id) == 0
2338 # Send messages BEFORE approval
2339 with conversations_session(token1) as api:
2340 api.SendMessage(
2341 conversations_pb2.SendMessageReq(
2342 group_chat_id=gc_id,
2343 text="Hello before approval",
2344 )
2345 )
2347 # Process the queued notification job
2348 while process_job():
2349 pass
2351 # User2 should STILL have no notifications (chat is SHADOWED)
2352 assert push_collector.count_for_user(user2.id) == 0
2354 # Now approve the group chat
2355 moderator.approve_group_chat(gc_id)
2357 # Process the queued notification jobs from approval
2358 while process_job():
2359 pass
2361 # Verify moderation state after approval
2362 with session_scope() as session:
2363 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2364 assert gc.moderation_state.visibility == ModerationVisibility.visible
2366 # User2 should have received 1 notification for the first message sent before approval
2367 push = push_collector.pop_for_user(user2.id, last=True)
2368 assert push.content.title == user1.name
2369 assert push.content.body == "Hello before approval"
2371 # Send a message AFTER approval
2372 with conversations_session(token1) as api:
2373 api.SendMessage(
2374 conversations_pb2.SendMessageReq(
2375 group_chat_id=gc_id,
2376 text="Hello after approval",
2377 )
2378 )
2380 # Process the queued notification job
2381 while process_job():
2382 pass
2384 # User2 should have received another notification
2385 assert push_collector.count_for_user(user2.id) == 1
2388def test_event_moderation_state_content(db):
2389 """Test that event moderation state content includes both title and description"""
2390 super_user, super_token = generate_user(is_superuser=True)
2391 user, token = generate_user()
2393 with session_scope() as session:
2394 create_community(session, 0, 2, "Community", [user], [], None)
2396 start_time = now() + timedelta(hours=2)
2397 end_time = start_time + timedelta(hours=3)
2399 with events_session(token) as api:
2400 res = api.CreateEvent(
2401 events_pb2.CreateEventReq(
2402 title="My Event Title",
2403 content="My event description.",
2404 photo_key=None,
2405 offline_information=events_pb2.OfflineEventInformation(
2406 address="Near Null Island",
2407 lat=0.1,
2408 lng=0.2,
2409 ),
2410 start_time=Timestamp_from_datetime(start_time),
2411 end_time=Timestamp_from_datetime(end_time),
2412 timezone="UTC",
2413 )
2414 )
2415 event_id = res.event_id
2417 with session_scope() as session:
2418 occurrence = session.execute(select(EventOccurrence).where(EventOccurrence.id == event_id)).scalar_one()
2419 state_id = occurrence.moderation_state_id
2421 with real_moderation_session(super_token) as api:
2422 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
2423 event_items = [
2424 item
2425 for item in res.queue_items
2426 if item.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE
2427 ]
2428 assert len(event_items) == 1
2429 assert event_items[0].moderation_state.content == "My Event Title\n\nMy event description."
2432# ============================================================================
2433# Tests for SetUserContentVisibility
2434# ============================================================================
2437def _get_moderation_state(session, object_type, object_id):
2438 return session.execute(
2439 select(ModerationState)
2440 .where(ModerationState.object_type == object_type)
2441 .where(ModerationState.object_id == object_id)
2442 ).scalar_one()
2445def test_SetUserContentVisibility_host_request(db):
2446 super_user, super_token = generate_user(is_superuser=True)
2447 surfer, surfer_token = generate_user()
2448 host, _ = generate_user()
2450 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2451 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2452 with requests_session(surfer_token) as api:
2453 hr_id = api.CreateHostRequest(
2454 requests_pb2.CreateHostRequestReq(
2455 host_user_id=host.id,
2456 from_date=today_plus_2,
2457 to_date=today_plus_3,
2458 text=valid_request_text(),
2459 )
2460 ).host_request_id
2462 with real_moderation_session(super_token) as api:
2463 res = api.SetUserContentVisibility(
2464 moderation_pb2.SetUserContentVisibilityReq(
2465 user_id=surfer.id,
2466 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2467 )
2468 )
2469 # Already shadowed by default — no-op
2470 assert res.updated_count == 0
2472 with real_moderation_session(super_token) as api:
2473 res = api.SetUserContentVisibility(
2474 moderation_pb2.SetUserContentVisibilityReq(
2475 user_id=surfer.id,
2476 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2477 reason="policy violation",
2478 )
2479 )
2480 assert res.updated_count == 1
2482 with session_scope() as session:
2483 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
2484 assert state.visibility == ModerationVisibility.hidden
2486 log_entries = (
2487 session.execute(
2488 select(ModerationLog)
2489 .where(ModerationLog.moderation_state_id == state.id)
2490 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc())
2491 )
2492 .scalars()
2493 .all()
2494 )
2495 # create log + bulk update log
2496 assert len(log_entries) == 2
2497 assert log_entries[-1].new_visibility == ModerationVisibility.hidden
2498 assert log_entries[-1].moderator_user_id == super_user.id
2499 assert log_entries[-1].reason == "policy violation"
2502def test_SetUserContentVisibility_group_chat(db):
2503 super_user, super_token = generate_user(is_superuser=True)
2504 creator, creator_token = generate_user()
2505 other, _ = generate_user()
2506 make_friends(creator, other)
2508 with conversations_session(creator_token) as api:
2509 gc_id = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[other.id])).group_chat_id
2511 with real_moderation_session(super_token) as api:
2512 api.SetUserContentVisibility(
2513 moderation_pb2.SetUserContentVisibilityReq(
2514 user_id=creator.id,
2515 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2516 )
2517 )
2519 with session_scope() as session:
2520 state = _get_moderation_state(session, ModerationObjectType.group_chat, gc_id)
2521 assert state.visibility == ModerationVisibility.hidden
2524def test_SetUserContentVisibility_event_occurrence(db):
2525 super_user, super_token = generate_user(is_superuser=True)
2526 creator, creator_token = generate_user()
2528 with session_scope() as session:
2529 create_community(session, 0, 2, "Community", [creator], [], None)
2531 start_time = now() + timedelta(hours=2)
2532 end_time = start_time + timedelta(hours=3)
2533 with events_session(creator_token) as api:
2534 event_id = api.CreateEvent(
2535 events_pb2.CreateEventReq(
2536 title="Event",
2537 content="Event description.",
2538 photo_key=None,
2539 offline_information=events_pb2.OfflineEventInformation(
2540 address="Near Null Island",
2541 lat=0.1,
2542 lng=0.2,
2543 ),
2544 start_time=Timestamp_from_datetime(start_time),
2545 end_time=Timestamp_from_datetime(end_time),
2546 timezone="UTC",
2547 )
2548 ).event_id
2550 with real_moderation_session(super_token) as api:
2551 res = api.SetUserContentVisibility(
2552 moderation_pb2.SetUserContentVisibilityReq(
2553 user_id=creator.id,
2554 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2555 )
2556 )
2557 assert res.updated_count == 1
2559 with session_scope() as session:
2560 state = _get_moderation_state(session, ModerationObjectType.event_occurrence, event_id)
2561 assert state.visibility == ModerationVisibility.hidden
2564def test_SetUserContentVisibility_friend_request(db):
2565 super_user, super_token = generate_user(is_superuser=True)
2566 sender, sender_token = generate_user()
2567 recipient, _ = generate_user()
2569 with api_session(sender_token) as api:
2570 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=recipient.id))
2572 with session_scope() as session:
2573 fr_id = session.execute(
2574 select(FriendRelationship.id).where(FriendRelationship.from_user_id == sender.id)
2575 ).scalar_one()
2577 with real_moderation_session(super_token) as api:
2578 res = api.SetUserContentVisibility(
2579 moderation_pb2.SetUserContentVisibilityReq(
2580 user_id=sender.id,
2581 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2582 )
2583 )
2584 assert res.updated_count == 1
2586 with session_scope() as session:
2587 state = _get_moderation_state(session, ModerationObjectType.friend_request, fr_id)
2588 assert state.visibility == ModerationVisibility.hidden
2591def test_SetUserContentVisibility_round_trip(db):
2592 super_user, super_token = generate_user(is_superuser=True)
2593 surfer, surfer_token = generate_user()
2594 host, _ = generate_user()
2596 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2597 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2598 with requests_session(surfer_token) as api:
2599 hr_id = api.CreateHostRequest(
2600 requests_pb2.CreateHostRequestReq(
2601 host_user_id=host.id,
2602 from_date=today_plus_2,
2603 to_date=today_plus_3,
2604 text=valid_request_text(),
2605 )
2606 ).host_request_id
2608 with real_moderation_session(super_token) as api:
2609 api.SetUserContentVisibility(
2610 moderation_pb2.SetUserContentVisibilityReq(
2611 user_id=surfer.id,
2612 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2613 reason="first",
2614 )
2615 )
2616 api.SetUserContentVisibility(
2617 moderation_pb2.SetUserContentVisibilityReq(
2618 user_id=surfer.id,
2619 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2620 reason="second",
2621 )
2622 )
2624 with session_scope() as session:
2625 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
2626 assert state.visibility == ModerationVisibility.visible
2628 bulk_log_entries = (
2629 session.execute(
2630 select(ModerationLog)
2631 .where(ModerationLog.moderation_state_id == state.id)
2632 .where(ModerationLog.reason.in_(("first", "second")))
2633 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc())
2634 )
2635 .scalars()
2636 .all()
2637 )
2638 assert [entry.new_visibility for entry in bulk_log_entries] == [
2639 ModerationVisibility.hidden,
2640 ModerationVisibility.visible,
2641 ]
2644def test_SetUserContentVisibility_resolves_queue_items(db):
2645 super_user, super_token = generate_user(is_superuser=True)
2646 surfer, surfer_token = generate_user()
2647 host, _ = generate_user()
2649 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2650 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2651 with requests_session(surfer_token) as api:
2652 hr_id = api.CreateHostRequest(
2653 requests_pb2.CreateHostRequestReq(
2654 host_user_id=host.id,
2655 from_date=today_plus_2,
2656 to_date=today_plus_3,
2657 text=valid_request_text(),
2658 )
2659 ).host_request_id
2661 with session_scope() as session:
2662 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
2663 queue_item = session.execute(
2664 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id)
2665 ).scalar_one()
2666 assert queue_item.resolved_by_log_id is None
2668 with real_moderation_session(super_token) as api:
2669 api.SetUserContentVisibility(
2670 moderation_pb2.SetUserContentVisibilityReq(
2671 user_id=surfer.id,
2672 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2673 )
2674 )
2676 with session_scope() as session:
2677 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
2678 queue_item = session.execute(
2679 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id)
2680 ).scalar_one()
2681 assert queue_item.resolved_by_log_id is not None
2684def test_SetUserContentVisibility_noop_when_matches(db):
2685 super_user, super_token = generate_user(is_superuser=True)
2686 surfer, surfer_token = generate_user()
2687 host, _ = generate_user()
2689 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2690 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2691 with requests_session(surfer_token) as api:
2692 api.CreateHostRequest(
2693 requests_pb2.CreateHostRequestReq(
2694 host_user_id=host.id,
2695 from_date=today_plus_2,
2696 to_date=today_plus_3,
2697 text=valid_request_text(),
2698 )
2699 )
2701 with session_scope() as session:
2702 log_count_before = len(session.execute(select(ModerationLog)).scalars().all())
2704 with real_moderation_session(super_token) as api:
2705 res = api.SetUserContentVisibility(
2706 moderation_pb2.SetUserContentVisibilityReq(
2707 user_id=surfer.id,
2708 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2709 )
2710 )
2711 assert res.updated_count == 0
2713 with session_scope() as session:
2714 log_count_after = len(session.execute(select(ModerationLog)).scalars().all())
2715 assert log_count_after == log_count_before
2718def test_SetUserContentVisibility_unspecified_rejected(db):
2719 super_user, super_token = generate_user(is_superuser=True)
2720 target, _ = generate_user()
2722 with real_moderation_session(super_token) as api:
2723 with pytest.raises(grpc.RpcError) as e:
2724 api.SetUserContentVisibility(
2725 moderation_pb2.SetUserContentVisibilityReq(
2726 user_id=target.id,
2727 visibility=moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED,
2728 )
2729 )
2730 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
2733def test_SetUserContentVisibility_non_admin_rejected(db):
2734 normal_user, normal_token = generate_user()
2735 target, _ = generate_user()
2737 with real_moderation_session(normal_token) as api:
2738 with pytest.raises(grpc.RpcError) as e:
2739 api.SetUserContentVisibility(
2740 moderation_pb2.SetUserContentVisibilityReq(
2741 user_id=target.id,
2742 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2743 )
2744 )
2745 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED
2748def test_SetUserContentVisibility_writes_admin_action(db):
2749 super_user, super_token = generate_user(is_superuser=True)
2750 surfer, surfer_token = generate_user()
2751 host, _ = generate_user()
2753 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2754 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2755 with requests_session(surfer_token) as api:
2756 api.CreateHostRequest(
2757 requests_pb2.CreateHostRequestReq(
2758 host_user_id=host.id,
2759 from_date=today_plus_2,
2760 to_date=today_plus_3,
2761 text=valid_request_text(),
2762 )
2763 )
2765 with real_moderation_session(super_token) as api:
2766 api.SetUserContentVisibility(
2767 moderation_pb2.SetUserContentVisibilityReq(
2768 user_id=surfer.id,
2769 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2770 reason="bulk hide",
2771 )
2772 )
2774 with session_scope() as session:
2775 actions = (
2776 session.execute(
2777 select(AdminAction)
2778 .where(AdminAction.target_user_id == surfer.id)
2779 .where(AdminAction.action_type == "set_user_content_visibility")
2780 )
2781 .scalars()
2782 .all()
2783 )
2784 assert len(actions) == 1
2785 assert actions[0].admin_user_id == super_user.id
2786 assert actions[0].tag == "hidden"
2787 assert actions[0].note == "bulk hide"
2790def test_SetUserContentVisibility_only_touches_target(db):
2791 super_user, super_token = generate_user(is_superuser=True)
2792 target, target_token = generate_user()
2793 other, other_token = generate_user()
2794 host, _ = generate_user()
2796 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2797 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2799 with requests_session(target_token) as api:
2800 target_hr_id = api.CreateHostRequest(
2801 requests_pb2.CreateHostRequestReq(
2802 host_user_id=host.id,
2803 from_date=today_plus_2,
2804 to_date=today_plus_3,
2805 text=valid_request_text(),
2806 )
2807 ).host_request_id
2809 with requests_session(other_token) as api:
2810 other_hr_id = api.CreateHostRequest(
2811 requests_pb2.CreateHostRequestReq(
2812 host_user_id=host.id,
2813 from_date=today_plus_2,
2814 to_date=today_plus_3,
2815 text=valid_request_text(),
2816 )
2817 ).host_request_id
2819 with real_moderation_session(super_token) as api:
2820 res = api.SetUserContentVisibility(
2821 moderation_pb2.SetUserContentVisibilityReq(
2822 user_id=target.id,
2823 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2824 )
2825 )
2826 assert res.updated_count == 1
2828 with session_scope() as session:
2829 target_state = _get_moderation_state(session, ModerationObjectType.host_request, target_hr_id)
2830 other_state = _get_moderation_state(session, ModerationObjectType.host_request, other_hr_id)
2831 assert target_state.visibility == ModerationVisibility.hidden
2832 assert other_state.visibility == ModerationVisibility.shadowed
2835def test_SetUserContentVisibility_user_not_found(db):
2836 super_user, super_token = generate_user(is_superuser=True)
2838 with real_moderation_session(super_token) as api:
2839 with pytest.raises(grpc.RpcError) as e:
2840 api.SetUserContentVisibility(
2841 moderation_pb2.SetUserContentVisibilityReq(
2842 user_id=999999,
2843 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2844 )
2845 )
2846 assert e.value.code() == grpc.StatusCode.NOT_FOUND