Coverage for app/backend/src/tests/test_moderation.py: 100%
1436 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1"""
2Comprehensive tests for the Unified Moderation System (UMS)
3"""
5from datetime import datetime, timedelta
7import grpc
8import pytest
9from google.protobuf import empty_pb2
10from sqlalchemy.sql import select
12from couchers.config import config
13from couchers.constants import MODERATION_AUTO_APPROVE_FLAG_PRIORITY
14from couchers.db import session_scope
15from couchers.jobs.handlers import auto_approve_moderation_queue
16from couchers.jobs.worker import process_job
17from couchers.models import (
18 AdminAction,
19 EventOccurrence,
20 FriendRelationship,
21 GroupChat,
22 HostRequest,
23 ModerationAction,
24 ModerationLog,
25 ModerationObjectType,
26 ModerationQueueItem,
27 ModerationState,
28 ModerationTrigger,
29 ModerationVisibility,
30 User,
31)
32from couchers.moderation.utils import create_moderation
33from couchers.proto import api_pb2, conversations_pb2, events_pb2, moderation_pb2, notifications_pb2, requests_pb2
34from couchers.utils import Timestamp_from_datetime, now, today
35from tests.fixtures.db import generate_user, make_friends
36from tests.fixtures.misc import EmailCollector, PushCollector, process_jobs
37from tests.fixtures.sessions import (
38 api_session,
39 conversations_session,
40 events_session,
41 notifications_session,
42 real_moderation_session,
43 requests_session,
44)
45from tests.test_communities import create_community
46from tests.test_requests import valid_request_text
49@pytest.fixture(autouse=True)
50def _(testconfig):
51 pass
54def create_test_host_request_with_moderation(surfer_token, host_user_id):
55 """Helper to create a host request and return its moderation state ID"""
56 today_plus_2 = (today() + timedelta(days=2)).isoformat()
57 today_plus_3 = (today() + timedelta(days=3)).isoformat()
59 with requests_session(surfer_token) as api:
60 hr_id = api.CreateHostRequest(
61 requests_pb2.CreateHostRequestReq(
62 host_user_id=host_user_id,
63 from_date=today_plus_2,
64 to_date=today_plus_3,
65 text=valid_request_text(),
66 )
67 ).host_request_id
69 with session_scope() as session:
70 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
71 return hr.moderation_state_id
74# ============================================================================
75# Tests for moderation helper functions
76# ============================================================================
79def test_create_moderation(db):
80 """Test creating a moderation state with associated log entry"""
81 user, _ = generate_user()
83 with session_scope() as session:
84 # Create a moderation state
85 moderation_state = create_moderation(
86 session=session,
87 object_type=ModerationObjectType.host_request,
88 object_id=123,
89 creator_user_id=user.id,
90 )
92 assert moderation_state.object_type == ModerationObjectType.host_request
93 assert moderation_state.object_id == 123
94 assert moderation_state.visibility == ModerationVisibility.shadowed
96 # Check that log entry was created
97 log_entries = (
98 session.execute(select(ModerationLog).where(ModerationLog.moderation_state_id == moderation_state.id))
99 .scalars()
100 .all()
101 )
103 assert len(log_entries) == 1
104 assert log_entries[0].action == ModerationAction.create
105 assert log_entries[0].reason == "Object created."
106 assert log_entries[0].moderator_user_id == user.id
109def test_add_to_moderation_queue(db):
110 """Test adding content to moderation queue via API"""
111 super_user, super_token = generate_user(is_superuser=True)
112 user1, token1 = generate_user()
113 user2, _ = generate_user()
115 today_plus_2 = (today() + timedelta(days=2)).isoformat()
116 today_plus_3 = (today() + timedelta(days=3)).isoformat()
118 # Create a real host request (which automatically creates moderation state and adds to queue)
119 with requests_session(token1) as api:
120 host_request_id = api.CreateHostRequest(
121 requests_pb2.CreateHostRequestReq(
122 host_user_id=user2.id,
123 from_date=today_plus_2,
124 to_date=today_plus_3,
125 text=valid_request_text(),
126 )
127 ).host_request_id
129 # Get the moderation state ID
130 state_id = None
131 with session_scope() as session:
132 host_request = session.execute(
133 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
134 ).scalar_one()
135 state_id = host_request.moderation_state_id
137 # Add another item to moderation queue via API (the first one was created automatically)
138 with real_moderation_session(super_token) as api:
139 api.ModerateContent(
140 moderation_pb2.ModerateContentReq(
141 moderation_state_id=state_id,
142 action=moderation_pb2.MODERATION_ACTION_FLAG,
143 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
144 reason="Admin manually flagged for additional review",
145 )
146 )
148 with session_scope() as session:
149 flag = session.execute(
150 select(ModerationQueueItem)
151 .where(ModerationQueueItem.moderation_state_id == state_id)
152 .where(ModerationQueueItem.trigger == ModerationTrigger.user_flag)
153 ).scalar_one()
154 assert flag.reason == "Admin manually flagged for additional review"
155 assert flag.resolved_by_log_id is None
157 # The FLAG action is recorded in the log, pointing at the new queue item.
158 flag_log = session.execute(
159 select(ModerationLog)
160 .where(ModerationLog.moderation_state_id == state_id)
161 .where(ModerationLog.action == ModerationAction.flag)
162 ).scalar_one()
163 assert flag_log.queue_item_id == flag.id
166def test_moderate_content(db):
167 """Test moderating content via API"""
168 super_user, super_token = generate_user(is_superuser=True)
169 user, token = generate_user()
170 host, _ = generate_user()
172 today_plus_2 = (today() + timedelta(days=2)).isoformat()
173 today_plus_3 = (today() + timedelta(days=3)).isoformat()
175 # Create a real host request
176 state_id = None
177 with requests_session(token) as api:
178 hr_id = api.CreateHostRequest(
179 requests_pb2.CreateHostRequestReq(
180 host_user_id=host.id,
181 from_date=today_plus_2,
182 to_date=today_plus_3,
183 text=valid_request_text(),
184 )
185 ).host_request_id
187 with session_scope() as session:
188 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
189 state_id = hr.moderation_state_id
191 # Moderate the content via API
192 with real_moderation_session(super_token) as api:
193 res = api.ModerateContent(
194 moderation_pb2.ModerateContentReq(
195 moderation_state_id=state_id,
196 action=moderation_pb2.MODERATION_ACTION_APPROVE,
197 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
198 reason="Content looks good",
199 )
200 )
202 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
204 # Check that state was updated in database
205 with session_scope() as session:
206 updated_state = session.get_one(ModerationState, state_id)
207 assert updated_state.visibility == ModerationVisibility.visible
209 # Check that log entry was created
210 log_entries = (
211 session.execute(
212 select(ModerationLog)
213 .where(ModerationLog.moderation_state_id == state_id)
214 .order_by(ModerationLog.time.desc(), ModerationLog.id.desc())
215 )
216 .scalars()
217 .all()
218 )
220 assert len(log_entries) == 2 # CREATE + APPROVE
221 assert log_entries[0].action == ModerationAction.approve
222 assert log_entries[0].moderator_user_id == super_user.id
223 assert log_entries[0].reason == "Content looks good"
226def test_resolve_queue_item(db):
227 """Test resolving a moderation queue item via ModerateContent API"""
228 user1, token1 = generate_user()
229 user2, _ = generate_user()
230 moderator, moderator_token = generate_user(is_superuser=True)
232 today_plus_2 = (today() + timedelta(days=2)).isoformat()
233 today_plus_3 = (today() + timedelta(days=3)).isoformat()
235 # Create a host request using the API (which automatically creates moderation state)
236 with requests_session(token1) as api:
237 host_request_id = api.CreateHostRequest(
238 requests_pb2.CreateHostRequestReq(
239 host_user_id=user2.id,
240 from_date=today_plus_2,
241 to_date=today_plus_3,
242 text=valid_request_text(),
243 )
244 ).host_request_id
246 state_id = None
247 with session_scope() as session:
248 # Get the host request and its moderation state
249 host_request = session.execute(
250 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
251 ).scalar_one()
252 state_id = host_request.moderation_state_id
254 # The moderation state should already exist and be in the queue
255 queue_item = session.execute(
256 select(ModerationQueueItem)
257 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
258 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
259 ).scalar_one()
261 assert queue_item.resolved_by_log_id is None
263 # Approve content with clear_flags, which resolves the open queue item(s)
264 with real_moderation_session(moderator_token) as api:
265 api.ModerateContent(
266 moderation_pb2.ModerateContentReq(
267 moderation_state_id=state_id,
268 action=moderation_pb2.MODERATION_ACTION_APPROVE,
269 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
270 reason="Approved after review",
271 clear_flags=True,
272 )
273 )
275 # Check that queue item was resolved
276 with session_scope() as session:
277 queue_item = session.execute(
278 select(ModerationQueueItem)
279 .where(ModerationQueueItem.moderation_state_id == state_id)
280 .where(ModerationQueueItem.resolved_by_log_id.is_not(None))
281 ).scalar_one()
282 assert queue_item.resolved_by_log_id is not None
285def test_approve_content_via_api(db):
286 """Test approving content via ModerateContent API"""
287 user1, token1 = generate_user()
288 user2, _ = generate_user()
289 moderator, moderator_token = generate_user(is_superuser=True)
291 today_plus_2 = (today() + timedelta(days=2)).isoformat()
292 today_plus_3 = (today() + timedelta(days=3)).isoformat()
294 # Create a host request using the API (which automatically creates moderation state)
295 with requests_session(token1) as api:
296 host_request_id = api.CreateHostRequest(
297 requests_pb2.CreateHostRequestReq(
298 host_user_id=user2.id,
299 from_date=today_plus_2,
300 to_date=today_plus_3,
301 text=valid_request_text(),
302 )
303 ).host_request_id
305 state_id = None
306 with session_scope() as session:
307 # Get the host request and its moderation state
308 host_request = session.execute(
309 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
310 ).scalar_one()
311 state_id = host_request.moderation_state_id
313 # Approve via API
314 with real_moderation_session(moderator_token) as api:
315 api.ModerateContent(
316 moderation_pb2.ModerateContentReq(
317 moderation_state_id=state_id,
318 action=moderation_pb2.MODERATION_ACTION_APPROVE,
319 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
320 reason="Quick approval",
321 )
322 )
324 # Check that state was updated to VISIBLE
325 with session_scope() as session:
326 updated_state = session.get_one(ModerationState, state_id)
327 assert updated_state.visibility == ModerationVisibility.visible
329 # Check log entry
330 log_entry = session.execute(
331 select(ModerationLog)
332 .where(ModerationLog.moderation_state_id == state_id)
333 .where(ModerationLog.action == ModerationAction.approve)
334 ).scalar_one()
336 assert log_entry.moderator_user_id == moderator.id
337 assert log_entry.reason == "Quick approval"
340# ============================================================================
341# Tests for host request moderation integration
342# ============================================================================
345def test_create_host_request_creates_moderation_state(db):
346 """Test that creating a host request automatically creates a moderation state"""
347 user1, token1 = generate_user()
348 user2, token2 = generate_user()
350 today_plus_2 = (today() + timedelta(days=2)).isoformat()
351 today_plus_3 = (today() + timedelta(days=3)).isoformat()
353 with requests_session(token1) as api:
354 host_request_id = api.CreateHostRequest(
355 requests_pb2.CreateHostRequestReq(
356 host_user_id=user2.id,
357 from_date=today_plus_2,
358 to_date=today_plus_3,
359 text=valid_request_text(),
360 )
361 ).host_request_id
363 with session_scope() as session:
364 # Check that host request has a moderation state
365 host_request = session.execute(
366 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
367 ).scalar_one()
369 # Check moderation state properties
370 moderation_state = session.execute(
371 select(ModerationState).where(ModerationState.id == host_request.moderation_state_id)
372 ).scalar_one()
374 assert moderation_state.object_type == ModerationObjectType.host_request
375 assert moderation_state.object_id == host_request_id
376 assert moderation_state.visibility == ModerationVisibility.shadowed
378 # Check that it was added to moderation queue
379 queue_items = (
380 session.execute(
381 select(ModerationQueueItem)
382 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
383 .where(ModerationQueueItem.resolved_by_log_id == None)
384 )
385 .scalars()
386 .all()
387 )
389 assert len(queue_items) == 1
390 assert queue_items[0].trigger == ModerationTrigger.initial_review
391 # item_author_user_id is no longer stored in the model, it's dynamically retrieved
394def test_host_request_no_notification_before_approval(db, push_collector: PushCollector):
395 """Test that host requests don't send notifications until approved"""
396 user1, token1 = generate_user()
397 user2, token2 = generate_user()
399 today_plus_2 = (today() + timedelta(days=2)).isoformat()
400 today_plus_3 = (today() + timedelta(days=3)).isoformat()
402 with requests_session(token1) as api:
403 host_request_id = api.CreateHostRequest(
404 requests_pb2.CreateHostRequestReq(
405 host_user_id=user2.id,
406 from_date=today_plus_2,
407 to_date=today_plus_3,
408 text=valid_request_text(),
409 )
410 ).host_request_id
412 # Process all jobs (including the notification job)
413 process_jobs()
415 # No push notification should be sent yet (host requests are shadowed initially)
416 assert push_collector.count_for_user(user2.id) == 0
419def test_shadowed_notification_not_in_list_notifications(db):
420 """Test that notifications for shadowed content don't appear in ListNotifications API"""
421 user1, token1 = generate_user()
422 user2, token2 = generate_user()
424 today_plus_2 = (today() + timedelta(days=2)).isoformat()
425 today_plus_3 = (today() + timedelta(days=3)).isoformat()
427 # Create a host request (which creates a shadowed notification for the host)
428 with requests_session(token1) as api:
429 host_request_id = api.CreateHostRequest(
430 requests_pb2.CreateHostRequestReq(
431 host_user_id=user2.id,
432 from_date=today_plus_2,
433 to_date=today_plus_3,
434 text=valid_request_text(),
435 )
436 ).host_request_id
438 # Host (recipient) should NOT see the notification in ListNotifications - it's for shadowed content
439 with notifications_session(token2) as api:
440 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
441 # Should be empty - the host request is still shadowed
442 assert len(res.notifications) == 0
445def test_notification_visible_after_approval(db):
446 """Test that notifications appear in ListNotifications after content is approved"""
447 user1, token1 = generate_user()
448 user2, token2 = generate_user()
449 mod, mod_token = generate_user(is_superuser=True)
451 today_plus_2 = (today() + timedelta(days=2)).isoformat()
452 today_plus_3 = (today() + timedelta(days=3)).isoformat()
454 # Create a host request (which creates a shadowed notification for the host)
455 with requests_session(token1) as api:
456 host_request_id = api.CreateHostRequest(
457 requests_pb2.CreateHostRequestReq(
458 host_user_id=user2.id,
459 from_date=today_plus_2,
460 to_date=today_plus_3,
461 text=valid_request_text(),
462 )
463 ).host_request_id
465 # Host (recipient) should NOT see the notification initially
466 with notifications_session(token2) as api:
467 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
468 assert len(res.notifications) == 0
470 # Get the moderation state ID and approve
471 with session_scope() as session:
472 host_request = session.execute(
473 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
474 ).scalar_one()
475 state_id = host_request.moderation_state_id
477 with real_moderation_session(mod_token) as api:
478 api.ModerateContent(
479 moderation_pb2.ModerateContentReq(
480 moderation_state_id=state_id,
481 action=moderation_pb2.MODERATION_ACTION_APPROVE,
482 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
483 reason="Looks good",
484 )
485 )
487 # Now host SHOULD see the notification
488 with notifications_session(token2) as api:
489 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
490 assert len(res.notifications) == 1
491 assert res.notifications[0].topic == "host_request"
492 assert res.notifications[0].action == "create"
495def test_shadowed_host_request_visible_to_author_only(db):
496 """Test that SHADOWED host requests are visible only to the author (surfer), not the recipient (host)"""
497 user1, token1 = generate_user()
498 user2, token2 = generate_user()
500 today_plus_2 = (today() + timedelta(days=2)).isoformat()
501 today_plus_3 = (today() + timedelta(days=3)).isoformat()
503 with requests_session(token1) as api:
504 host_request_id = api.CreateHostRequest(
505 requests_pb2.CreateHostRequestReq(
506 host_user_id=user2.id,
507 from_date=today_plus_2,
508 to_date=today_plus_3,
509 text=valid_request_text(),
510 )
511 ).host_request_id
513 # Surfer (author) can see it with GetHostRequest
514 with requests_session(token1) as api:
515 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
516 assert res.host_request_id == host_request_id
517 assert res.latest_message.text.text == valid_request_text()
519 # Host (recipient) CANNOT see it with GetHostRequest - it's shadowed
520 with requests_session(token2) as api:
521 with pytest.raises(grpc.RpcError) as e:
522 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
523 assert e.value.code() == grpc.StatusCode.NOT_FOUND
526def test_unlisted_host_request_not_in_lists(db):
527 """Test that SHADOWED host requests are visible to author but not to recipient"""
528 user1, token1 = generate_user()
529 user2, token2 = generate_user()
531 today_plus_2 = (today() + timedelta(days=2)).isoformat()
532 today_plus_3 = (today() + timedelta(days=3)).isoformat()
534 with requests_session(token1) as api:
535 host_request_id = api.CreateHostRequest(
536 requests_pb2.CreateHostRequestReq(
537 host_user_id=user2.id,
538 from_date=today_plus_2,
539 to_date=today_plus_3,
540 text=valid_request_text(),
541 )
542 ).host_request_id
544 # Surfer (author) should see it in their sent list even though it's SHADOWED
545 with requests_session(token1) as api:
546 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
547 assert len(res.host_requests) == 1
549 # Host should NOT see it in their received list (still SHADOWED from them)
550 with requests_session(token2) as api:
551 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
552 assert len(res.host_requests) == 0
555def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector):
556 """Test that approved host requests appear in lists and send notifications"""
557 user1, token1 = generate_user()
558 user2, token2 = generate_user()
559 mod, mod_token = generate_user(is_superuser=True)
561 today_plus_2 = (today() + timedelta(days=2)).isoformat()
562 today_plus_3 = (today() + timedelta(days=3)).isoformat()
564 with requests_session(token1) as api:
565 host_request_id = api.CreateHostRequest(
566 requests_pb2.CreateHostRequestReq(
567 host_user_id=user2.id,
568 from_date=today_plus_2,
569 to_date=today_plus_3,
570 text=valid_request_text(),
571 )
572 ).host_request_id
574 # Process the initial notification job - should be deferred (no notification sent)
575 process_jobs()
576 assert push_collector.count_for_user(user2.id) == 0
578 # Get the moderation state ID
579 state_id = None
580 with session_scope() as session:
581 host_request = session.execute(
582 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
583 ).scalar_one()
584 state_id = host_request.moderation_state_id
586 # Approve the host request via API
587 with real_moderation_session(mod_token) as api:
588 api.ModerateContent(
589 moderation_pb2.ModerateContentReq(
590 moderation_state_id=state_id,
591 action=moderation_pb2.MODERATION_ACTION_APPROVE,
592 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
593 reason="Looks good",
594 )
595 )
597 # Process the re-queued notification job - should now send notification
598 process_jobs()
600 # Now surfer SHOULD see it in their sent list
601 with requests_session(token1) as api:
602 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
603 assert len(res.host_requests) == 1
604 assert res.host_requests[0].host_request_id == host_request_id
606 # Host SHOULD see it in their received list
607 with requests_session(token2) as api:
608 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
609 assert len(res.host_requests) == 1
610 assert res.host_requests[0].host_request_id == host_request_id
612 # After approval, the host should have received a push notification
613 assert push_collector.pop_for_user(user2.id, last=True).topic_action == "host_request:create"
616def test_hidden_host_request_invisible_to_all(db):
617 """Test that HIDDEN host requests are invisible to everyone except moderators"""
618 user1, token1 = generate_user()
619 user2, token2 = generate_user()
620 user3, token3 = generate_user() # Third party
621 moderator, moderator_token = generate_user(is_superuser=True)
623 today_plus_2 = (today() + timedelta(days=2)).isoformat()
624 today_plus_3 = (today() + timedelta(days=3)).isoformat()
626 with requests_session(token1) as api:
627 host_request_id = api.CreateHostRequest(
628 requests_pb2.CreateHostRequestReq(
629 host_user_id=user2.id,
630 from_date=today_plus_2,
631 to_date=today_plus_3,
632 text=valid_request_text(),
633 )
634 ).host_request_id
636 # Get the moderation state ID
637 state_id = None
638 with session_scope() as session:
639 host_request = session.execute(
640 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
641 ).scalar_one()
642 state_id = host_request.moderation_state_id
644 # Hide the host request via API (e.g., spam/abuse)
645 with real_moderation_session(moderator_token) as api:
646 api.ModerateContent(
647 moderation_pb2.ModerateContentReq(
648 moderation_state_id=state_id,
649 action=moderation_pb2.MODERATION_ACTION_HIDE,
650 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
651 reason="Spam content",
652 )
653 )
655 # Surfer can't see it with GetHostRequest
656 with requests_session(token1) as api:
657 with pytest.raises(grpc.RpcError) as e:
658 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
659 assert e.value.code() == grpc.StatusCode.NOT_FOUND
661 # Host can't see it with GetHostRequest
662 with requests_session(token2) as api:
663 with pytest.raises(grpc.RpcError) as e:
664 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
665 assert e.value.code() == grpc.StatusCode.NOT_FOUND
667 # Third party definitely can't see it
668 with requests_session(token3) as api:
669 with pytest.raises(grpc.RpcError) as e:
670 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
671 assert e.value.code() == grpc.StatusCode.NOT_FOUND
673 # Not in any lists
674 with requests_session(token1) as api:
675 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
676 assert len(res.host_requests) == 0
678 with requests_session(token2) as api:
679 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
680 assert len(res.host_requests) == 0
683def test_multiple_host_requests_listing_visibility(db):
684 """Test that ListHostRequests correctly filters based on moderation state"""
685 user1, token1 = generate_user()
686 user2, token2 = generate_user()
687 moderator, moderator_token = generate_user(is_superuser=True)
689 today_plus_2 = (today() + timedelta(days=2)).isoformat()
690 today_plus_3 = (today() + timedelta(days=3)).isoformat()
692 # Create 3 host requests
693 host_request_ids = []
694 state_ids = []
695 with requests_session(token1) as api:
696 for i in range(3):
697 hr_id = api.CreateHostRequest(
698 requests_pb2.CreateHostRequestReq(
699 host_user_id=user2.id,
700 from_date=today_plus_2,
701 to_date=today_plus_3,
702 text=valid_request_text(f"Test request {i + 1}"),
703 )
704 ).host_request_id
705 host_request_ids.append(hr_id)
707 # Get state IDs
708 with session_scope() as session:
709 for hr_id in host_request_ids:
710 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
711 state_ids.append(host_request.moderation_state_id)
713 # Approve the first one via API
714 with real_moderation_session(moderator_token) as api:
715 api.ModerateContent(
716 moderation_pb2.ModerateContentReq(
717 moderation_state_id=state_ids[0],
718 action=moderation_pb2.MODERATION_ACTION_APPROVE,
719 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
720 reason="Approved",
721 )
722 )
724 # Hide the third one via API
725 with real_moderation_session(moderator_token) as api:
726 api.ModerateContent(
727 moderation_pb2.ModerateContentReq(
728 moderation_state_id=state_ids[2],
729 action=moderation_pb2.MODERATION_ACTION_HIDE,
730 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
731 reason="Spam",
732 )
733 )
735 # Surfer should see the approved one and the shadowed one (author can see their SHADOWED content)
736 with requests_session(token1) as api:
737 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
738 assert len(res.host_requests) == 2
739 visible_ids = {hr.host_request_id for hr in res.host_requests}
740 assert visible_ids == {host_request_ids[0], host_request_ids[1]}
742 # Host should see only the approved one in received list
743 with requests_session(token2) as api:
744 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
745 assert len(res.host_requests) == 1
746 assert res.host_requests[0].host_request_id == host_request_ids[0]
749def test_moderation_log_tracking(db):
750 """Test that moderation actions are properly logged via API"""
751 user, user_token = generate_user()
752 host, _ = generate_user()
753 moderator1, moderator1_token = generate_user(is_superuser=True)
754 moderator2, moderator2_token = generate_user(is_superuser=True)
756 # Create a real host request
757 state_id = create_test_host_request_with_moderation(user_token, host.id)
759 # Perform several moderation actions via API
760 with real_moderation_session(moderator1_token) as api:
761 api.ModerateContent(
762 moderation_pb2.ModerateContentReq(
763 moderation_state_id=state_id,
764 action=moderation_pb2.MODERATION_ACTION_APPROVE,
765 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
766 reason="Looks good initially",
767 )
768 )
770 with real_moderation_session(moderator2_token) as api:
771 api.ModerateContent(
772 moderation_pb2.ModerateContentReq(
773 moderation_state_id=state_id,
774 action=moderation_pb2.MODERATION_ACTION_FLAG,
775 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
776 reason="Wait, this needs another look",
777 )
778 )
779 # Shadow it back
780 api.ModerateContent(
781 moderation_pb2.ModerateContentReq(
782 moderation_state_id=state_id,
783 action=moderation_pb2.MODERATION_ACTION_HIDE,
784 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
785 reason="Wait, this needs another look",
786 )
787 )
789 with real_moderation_session(moderator1_token) as api:
790 api.ModerateContent(
791 moderation_pb2.ModerateContentReq(
792 moderation_state_id=state_id,
793 action=moderation_pb2.MODERATION_ACTION_HIDE,
794 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
795 reason="Actually it's spam",
796 )
797 )
799 # Check all log entries
800 with session_scope() as session:
801 log_entries = (
802 session.execute(
803 select(ModerationLog)
804 .where(ModerationLog.moderation_state_id == state_id)
805 .order_by(ModerationLog.time.asc())
806 )
807 .scalars()
808 .all()
809 )
811 # CREATE + APPROVE + HIDE + HIDE (shadowing back counts as HIDE action)
812 assert len(log_entries) >= 3
814 assert log_entries[0].action == ModerationAction.create
815 assert log_entries[0].moderator_user_id == user.id
816 assert log_entries[0].reason == "Object created."
818 assert log_entries[1].action == ModerationAction.approve
819 assert log_entries[1].moderator_user_id == moderator1.id
820 assert log_entries[1].reason == "Looks good initially"
822 # The last action should be hiding
823 assert log_entries[-1].action == ModerationAction.hide
824 assert log_entries[-1].moderator_user_id == moderator1.id
825 assert log_entries[-1].reason == "Actually it's spam"
828def test_moderation_queue_workflow(db):
829 """Test the full moderation queue workflow via API"""
830 user1, token1 = generate_user()
831 user2, _ = generate_user()
832 moderator, moderator_token = generate_user(is_superuser=True)
834 today_plus_2 = (today() + timedelta(days=2)).isoformat()
835 today_plus_3 = (today() + timedelta(days=3)).isoformat()
837 # Create a host request using the API (which automatically creates moderation state and adds to queue)
838 with requests_session(token1) as api:
839 host_request_id = api.CreateHostRequest(
840 requests_pb2.CreateHostRequestReq(
841 host_user_id=user2.id,
842 from_date=today_plus_2,
843 to_date=today_plus_3,
844 text=valid_request_text(),
845 )
846 ).host_request_id
848 state_id = None
849 queue_item_id = None
850 with session_scope() as session:
851 # Get the host request and its moderation state
852 host_request = session.execute(
853 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
854 ).scalar_one()
855 state_id = host_request.moderation_state_id
857 # The queue item should already exist (created automatically)
858 queue_item = session.execute(
859 select(ModerationQueueItem)
860 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
861 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
862 ).scalar_one()
863 queue_item_id = queue_item.id
865 # Verify it's in the queue
866 unresolved_items = (
867 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
868 .scalars()
869 .all()
870 )
872 assert len(unresolved_items) >= 1
873 assert queue_item.id in [item.id for item in unresolved_items]
875 # Moderator reviews and approves via API, clearing the open queue item
876 with real_moderation_session(moderator_token) as api:
877 api.ModerateContent(
878 moderation_pb2.ModerateContentReq(
879 moderation_state_id=state_id,
880 action=moderation_pb2.MODERATION_ACTION_APPROVE,
881 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
882 reason="Content approved",
883 clear_flags=True,
884 )
885 )
887 # Verify queue item was resolved
888 with session_scope() as session:
889 # Verify it's no longer in unresolved queue
890 unresolved_items = (
891 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
892 .scalars()
893 .all()
894 )
896 assert queue_item_id not in [item.id for item in unresolved_items]
898 # Verify the queue item was linked to a log entry
899 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
900 assert queue_item.resolved_by_log_id is not None
903# ============================================================================
904# Moderation API Tests (testing the gRPC servicer)
905# ============================================================================
908def test_GetModerationQueue_empty(db):
909 """Test getting an empty moderation queue"""
910 super_user, super_token = generate_user(is_superuser=True)
912 with real_moderation_session(super_token) as api:
913 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
914 assert len(res.queue_items) == 0
915 assert res.next_page_token == ""
918def test_GetModerationQueue_with_items(db):
919 """Test getting moderation queue with items 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 some host requests (which automatically adds them to moderation queue)
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 with real_moderation_session(super_token) as api:
929 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
930 assert len(res.queue_items) == 2
931 assert res.queue_items[0].is_resolved == False
932 assert res.queue_items[1].is_resolved == False
935def test_GetModerationQueue_filter_by_trigger(db):
936 """Test filtering moderation queue by trigger type via API"""
937 super_user, super_token = generate_user(is_superuser=True)
938 normal_user, user_token = generate_user()
939 host, _ = generate_user()
941 # Create host requests (which automatically adds them to moderation queue with INITIAL_REVIEW)
942 state1_id = create_test_host_request_with_moderation(user_token, host.id)
943 state2_id = create_test_host_request_with_moderation(user_token, host.id)
945 # Add USER_FLAG trigger to second item via API
946 with real_moderation_session(super_token) as api:
947 api.ModerateContent(
948 moderation_pb2.ModerateContentReq(
949 moderation_state_id=state2_id,
950 action=moderation_pb2.MODERATION_ACTION_FLAG,
951 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
952 reason="Reported by user",
953 )
954 )
956 # Filter by INITIAL_REVIEW (should get first item and maybe both depending on how queue works)
957 with real_moderation_session(super_token) as api:
958 res = api.GetModerationQueue(
959 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW])
960 )
961 assert len(res.queue_items) == 2 # Both have INITIAL_REVIEW triggers
962 assert all(item.trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW for item in res.queue_items)
964 # Filter by USER_FLAG (should get second item only)
965 with real_moderation_session(super_token) as api:
966 res = api.GetModerationQueue(
967 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_USER_FLAG])
968 )
969 assert len(res.queue_items) == 1
970 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG
973def test_GetModerationQueue_filter_created_before(db):
974 """Test filtering moderation queue by created_before timestamp"""
975 super_user, super_token = generate_user(is_superuser=True)
976 normal_user, user_token = generate_user()
977 host, _ = generate_user()
979 # Create host requests
980 state1_id = create_test_host_request_with_moderation(user_token, host.id)
981 state2_id = create_test_host_request_with_moderation(user_token, host.id)
983 # Backdate the first queue item
984 with session_scope() as session:
985 queue_item1 = session.execute(
986 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
987 ).scalar_one()
988 # Set it to 2 hours ago
989 queue_item1.time_created = now() - timedelta(hours=2)
991 # The second item remains at current time
993 # Filter to items created before 1 hour ago (should only get the first item)
994 cutoff_time = now() - timedelta(hours=1)
995 with real_moderation_session(super_token) as api:
996 res = api.GetModerationQueue(
997 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(cutoff_time))
998 )
999 assert len(res.queue_items) == 1
1000 assert res.queue_items[0].moderation_state_id == state1_id
1002 # Filter to items created before now (should get both)
1003 with real_moderation_session(super_token) as api:
1004 res = api.GetModerationQueue(
1005 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(now() + timedelta(seconds=10)))
1006 )
1007 assert len(res.queue_items) == 2
1009 # Filter to items created before 3 hours ago (should get none)
1010 old_cutoff = now() - timedelta(hours=3)
1011 with real_moderation_session(super_token) as api:
1012 res = api.GetModerationQueue(
1013 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(old_cutoff))
1014 )
1015 assert len(res.queue_items) == 0
1018def test_GetModerationQueue_filter_created_after(db):
1019 """Test filtering moderation queue by created_after timestamp"""
1020 super_user, super_token = generate_user(is_superuser=True)
1021 normal_user, user_token = generate_user()
1022 host, _ = generate_user()
1024 # Create host requests
1025 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1026 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1028 # Backdate the first queue item to 2 hours ago
1029 with session_scope() as session:
1030 queue_item1 = session.execute(
1031 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1032 ).scalar_one()
1033 queue_item1.time_created = now() - timedelta(hours=2)
1035 # The second item remains at current time
1037 # Filter to items created after 1 hour ago (should only get the second item)
1038 cutoff_time = now() - timedelta(hours=1)
1039 with real_moderation_session(super_token) as api:
1040 res = api.GetModerationQueue(
1041 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(cutoff_time))
1042 )
1043 assert len(res.queue_items) == 1
1044 assert res.queue_items[0].moderation_state_id == state2_id
1046 # Filter to items created after 3 hours ago (should get both)
1047 old_cutoff = now() - timedelta(hours=3)
1048 with real_moderation_session(super_token) as api:
1049 res = api.GetModerationQueue(
1050 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(old_cutoff))
1051 )
1052 assert len(res.queue_items) == 2
1054 # Filter to items created after now (should get none)
1055 with real_moderation_session(super_token) as api:
1056 res = api.GetModerationQueue(
1057 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(now() + timedelta(seconds=10)))
1058 )
1059 assert len(res.queue_items) == 0
1062def test_GetModerationQueue_filter_created_before_and_after(db):
1063 """Test filtering moderation queue by both created_before and created_after timestamps"""
1064 super_user, super_token = generate_user(is_superuser=True)
1065 normal_user, user_token = generate_user()
1066 host, _ = generate_user()
1068 # Create 3 host requests
1069 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1070 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1071 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1073 # Set different times: state1 = 3 hours ago, state2 = 1.5 hours ago, state3 = now
1074 with session_scope() as session:
1075 queue_item1 = session.execute(
1076 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1077 ).scalar_one()
1078 queue_item1.time_created = now() - timedelta(hours=3)
1080 queue_item2 = session.execute(
1081 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1082 ).scalar_one()
1083 queue_item2.time_created = now() - timedelta(hours=1, minutes=30)
1085 # Filter to items between 2 hours ago and 1 hour ago (should only get state2)
1086 after_cutoff = now() - timedelta(hours=2)
1087 before_cutoff = now() - timedelta(hours=1)
1088 with real_moderation_session(super_token) as api:
1089 res = api.GetModerationQueue(
1090 moderation_pb2.GetModerationQueueReq(
1091 created_after=Timestamp_from_datetime(after_cutoff),
1092 created_before=Timestamp_from_datetime(before_cutoff),
1093 )
1094 )
1095 assert len(res.queue_items) == 1
1096 assert res.queue_items[0].moderation_state_id == state2_id
1098 # Filter to items between 4 hours ago and 2.5 hours ago (should only get state1)
1099 after_cutoff = now() - timedelta(hours=4)
1100 before_cutoff = now() - timedelta(hours=2, minutes=30)
1101 with real_moderation_session(super_token) as api:
1102 res = api.GetModerationQueue(
1103 moderation_pb2.GetModerationQueueReq(
1104 created_after=Timestamp_from_datetime(after_cutoff),
1105 created_before=Timestamp_from_datetime(before_cutoff),
1106 )
1107 )
1108 assert len(res.queue_items) == 1
1109 assert res.queue_items[0].moderation_state_id == state1_id
1112def test_GetModerationQueue_filter_unresolved(db):
1113 """Test filtering moderation queue for unresolved items only via API"""
1114 super_user, super_token = generate_user(is_superuser=True)
1115 normal_user, user_token = generate_user()
1116 host, _ = generate_user()
1118 # Create 2 host requests
1119 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1120 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1122 # Resolve the first one via API (approve with clear_flags resolves the queue item)
1123 with real_moderation_session(super_token) as api:
1124 api.ModerateContent(
1125 moderation_pb2.ModerateContentReq(
1126 moderation_state_id=state1_id,
1127 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1128 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1129 reason="Approved",
1130 clear_flags=True,
1131 )
1132 )
1134 # Get all items
1135 with real_moderation_session(super_token) as api:
1136 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1137 assert len(res.queue_items) == 2
1139 # Get only unresolved items
1140 with real_moderation_session(super_token) as api:
1141 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1142 assert len(res.queue_items) == 1
1143 assert res.queue_items[0].is_resolved == False
1146def test_GetModerationQueue_filter_by_author(db):
1147 """Test filtering moderation queue by item_author_user_id"""
1148 super_user, super_token = generate_user(is_superuser=True)
1149 user1, token1 = generate_user()
1150 user2, token2 = generate_user()
1151 host_user, _ = generate_user()
1153 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1154 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1156 # Create 2 host requests by user1
1157 with requests_session(token1) as api:
1158 hr1_id = api.CreateHostRequest(
1159 requests_pb2.CreateHostRequestReq(
1160 host_user_id=host_user.id,
1161 from_date=today_plus_2,
1162 to_date=today_plus_3,
1163 text=valid_request_text(),
1164 )
1165 ).host_request_id
1167 hr2_id = api.CreateHostRequest(
1168 requests_pb2.CreateHostRequestReq(
1169 host_user_id=host_user.id,
1170 from_date=today_plus_2,
1171 to_date=today_plus_3,
1172 text=valid_request_text(),
1173 )
1174 ).host_request_id
1176 # Create 1 host request by user2
1177 with requests_session(token2) as api:
1178 hr3_id = api.CreateHostRequest(
1179 requests_pb2.CreateHostRequestReq(
1180 host_user_id=host_user.id,
1181 from_date=today_plus_2,
1182 to_date=today_plus_3,
1183 text=valid_request_text(),
1184 )
1185 ).host_request_id
1187 # Get moderation state IDs
1188 state1_id, state2_id, state3_id = None, None, None
1189 with session_scope() as session:
1190 hr1 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr1_id)).scalar_one()
1191 hr2 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr2_id)).scalar_one()
1192 hr3 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr3_id)).scalar_one()
1193 state1_id = hr1.moderation_state_id
1194 state2_id = hr2.moderation_state_id
1195 state3_id = hr3.moderation_state_id
1197 # Get all items (should be 3)
1198 with real_moderation_session(super_token) as api:
1199 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1200 assert len(res.queue_items) == 3
1202 # Filter by user1 (should get 2)
1203 with real_moderation_session(super_token) as api:
1204 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user1.id))
1205 assert len(res.queue_items) == 2
1206 assert all(item.moderation_state.author.user_id == user1.id for item in res.queue_items)
1208 # Filter by user2 (should get 1)
1209 with real_moderation_session(super_token) as api:
1210 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user2.id))
1211 assert len(res.queue_items) == 1
1212 assert res.queue_items[0].moderation_state.author.user_id == user2.id
1213 assert res.queue_items[0].moderation_state_id == state3_id
1215 # Filter by non-existent user (should get 0)
1216 with real_moderation_session(super_token) as api:
1217 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=999999))
1218 assert len(res.queue_items) == 0
1221def test_GetModerationQueue_ordering(db):
1222 """Test ordering moderation queue by oldest/newest first"""
1223 super_user, super_token = generate_user(is_superuser=True)
1224 normal_user, user_token = generate_user()
1225 host, _ = generate_user()
1227 # Create 3 host requests
1228 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1229 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1230 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1232 # Set different times: state1 = 3 hours ago, state2 = 2 hours ago, state3 = 1 hour ago
1233 with session_scope() as session:
1234 queue_item1 = session.execute(
1235 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1236 ).scalar_one()
1237 queue_item1.time_created = now() - timedelta(hours=3)
1239 queue_item2 = session.execute(
1240 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1241 ).scalar_one()
1242 queue_item2.time_created = now() - timedelta(hours=2)
1244 queue_item3 = session.execute(
1245 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state3_id)
1246 ).scalar_one()
1247 queue_item3.time_created = now() - timedelta(hours=1)
1249 # Default order (oldest first)
1250 with real_moderation_session(super_token) as api:
1251 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1252 assert len(res.queue_items) == 3
1253 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1254 assert res.queue_items[1].moderation_state_id == state2_id
1255 assert res.queue_items[2].moderation_state_id == state3_id # newest
1257 # Explicit oldest first
1258 with real_moderation_session(super_token) as api:
1259 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=False))
1260 assert len(res.queue_items) == 3
1261 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1262 assert res.queue_items[2].moderation_state_id == state3_id # newest
1264 # Newest first
1265 with real_moderation_session(super_token) as api:
1266 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=True))
1267 assert len(res.queue_items) == 3
1268 assert res.queue_items[0].moderation_state_id == state3_id # newest
1269 assert res.queue_items[1].moderation_state_id == state2_id
1270 assert res.queue_items[2].moderation_state_id == state1_id # oldest
1273def test_GetModerationQueue_pagination_newest_first(db):
1274 """Test pagination with newest_first=True returns different items on each page"""
1275 super_user, super_token = generate_user(is_superuser=True)
1276 normal_user, normal_token = generate_user()
1277 host_user, _ = generate_user()
1279 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1280 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1282 # Create 5 host requests
1283 hr_ids = []
1284 with requests_session(normal_token) as api:
1285 for i in range(5):
1286 hr_id = api.CreateHostRequest(
1287 requests_pb2.CreateHostRequestReq(
1288 host_user_id=host_user.id,
1289 from_date=today_plus_2,
1290 to_date=today_plus_3,
1291 text=valid_request_text(),
1292 )
1293 ).host_request_id
1294 hr_ids.append(hr_id)
1296 # Get moderation state IDs
1297 state_ids = []
1298 with session_scope() as session:
1299 for hr_id in hr_ids:
1300 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
1301 state_ids.append(hr.moderation_state_id)
1303 # Set different times so ordering is deterministic
1304 with session_scope() as session:
1305 for i, state_id in enumerate(state_ids):
1306 queue_item = session.execute(
1307 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
1308 ).scalar_one()
1309 queue_item.time_created = now() - timedelta(hours=5 - i) # oldest first in list
1311 # Get first page (2 items) with newest_first=True, filtered to our user's items
1312 with real_moderation_session(super_token) as api:
1313 res1 = api.GetModerationQueue(
1314 moderation_pb2.GetModerationQueueReq(page_size=2, newest_first=True, item_author_user_id=normal_user.id)
1315 )
1316 assert len(res1.queue_items) == 2
1317 # Should get newest items: state_ids[4], state_ids[3]
1318 assert res1.queue_items[0].moderation_state_id == state_ids[4]
1319 assert res1.queue_items[1].moderation_state_id == state_ids[3]
1320 assert res1.next_page_token # should have more pages
1322 # Get second page using the token
1323 res2 = api.GetModerationQueue(
1324 moderation_pb2.GetModerationQueueReq(
1325 page_size=2, newest_first=True, page_token=res1.next_page_token, item_author_user_id=normal_user.id
1326 )
1327 )
1328 assert len(res2.queue_items) == 2
1329 # Should get next newest items: state_ids[2], state_ids[1]
1330 assert res2.queue_items[0].moderation_state_id == state_ids[2]
1331 assert res2.queue_items[1].moderation_state_id == state_ids[1]
1333 # Pages should not overlap
1334 page1_ids = {item.moderation_state_id for item in res1.queue_items}
1335 page2_ids = {item.moderation_state_id for item in res2.queue_items}
1336 assert page1_ids.isdisjoint(page2_ids), "Pages should not have overlapping items"
1339def test_GetModerationLog(db):
1340 """Test getting moderation log for a state via API"""
1341 super_user, super_token = generate_user(is_superuser=True)
1342 moderator, moderator_token = generate_user(is_superuser=True)
1343 normal_user, user_token = generate_user()
1344 host, _ = generate_user()
1346 # Create a real host request
1347 state_id = create_test_host_request_with_moderation(user_token, host.id)
1349 # Perform a moderation action via API
1350 with real_moderation_session(moderator_token) as api:
1351 api.ModerateContent(
1352 moderation_pb2.ModerateContentReq(
1353 moderation_state_id=state_id,
1354 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1355 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1356 reason="Looks good",
1357 )
1358 )
1360 with real_moderation_session(super_token) as api:
1361 res = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
1362 assert len(res.log_entries) == 2 # CREATE + APPROVE
1363 assert res.moderation_state.moderation_state_id == state_id
1364 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1365 # Log entries are in reverse chronological order
1366 assert res.log_entries[0].action == moderation_pb2.MODERATION_ACTION_APPROVE
1367 assert res.log_entries[0].moderator_user_id == moderator.id
1368 assert res.log_entries[0].reason == "Looks good"
1369 assert res.log_entries[1].action == moderation_pb2.MODERATION_ACTION_CREATE
1370 assert res.log_entries[1].moderator_user_id == normal_user.id
1373def test_GetModerationLog_not_found(db):
1374 """Test getting moderation log for non-existent state"""
1375 super_user, super_token = generate_user(is_superuser=True)
1377 with real_moderation_session(super_token) as api:
1378 with pytest.raises(grpc.RpcError) as e:
1379 api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=999999))
1380 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1381 assert e.value.details() == "Moderation state not found."
1384def test_GetModerationState(db):
1385 """Test getting moderation state by object type and ID"""
1386 super_user, super_token = generate_user(is_superuser=True)
1387 user1, token1 = generate_user()
1388 user2, _ = generate_user()
1390 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1391 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1393 with requests_session(token1) as api:
1394 host_request_id = api.CreateHostRequest(
1395 requests_pb2.CreateHostRequestReq(
1396 host_user_id=user2.id,
1397 from_date=today_plus_2,
1398 to_date=today_plus_3,
1399 text=valid_request_text(),
1400 )
1401 ).host_request_id
1403 with real_moderation_session(super_token) as api:
1404 res = api.GetModerationState(
1405 moderation_pb2.GetModerationStateReq(
1406 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1407 object_id=host_request_id,
1408 )
1409 )
1410 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST
1411 assert res.moderation_state.object_id == host_request_id
1412 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1413 assert res.moderation_state.moderation_state_id > 0
1416def test_GetModerationState_not_found(db):
1417 """Test getting moderation state for non-existent object"""
1418 super_user, super_token = generate_user(is_superuser=True)
1420 with real_moderation_session(super_token) as api:
1421 with pytest.raises(grpc.RpcError) as e:
1422 api.GetModerationState(
1423 moderation_pb2.GetModerationStateReq(
1424 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1425 object_id=999999,
1426 )
1427 )
1428 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1429 assert e.value.details() == "Moderation state not found."
1432def test_GetModerationState_unspecified_type(db):
1433 """Test getting moderation state with unspecified object type"""
1434 super_user, super_token = generate_user(is_superuser=True)
1436 with real_moderation_session(super_token) as api:
1437 with pytest.raises(grpc.RpcError) as e:
1438 api.GetModerationState(
1439 moderation_pb2.GetModerationStateReq(
1440 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED,
1441 object_id=123,
1442 )
1443 )
1444 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1445 assert e.value.details() == "Object type must be specified."
1448def test_ModerateContent_approve(db):
1449 """Test approving content via unified moderation API"""
1450 super_user, super_token = generate_user(is_superuser=True)
1451 user1, token1 = generate_user()
1452 user2, _ = generate_user()
1454 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1455 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1457 # Create a host request using the API (which automatically creates moderation state)
1458 with requests_session(token1) as api:
1459 host_request_id = api.CreateHostRequest(
1460 requests_pb2.CreateHostRequestReq(
1461 host_user_id=user2.id,
1462 from_date=today_plus_2,
1463 to_date=today_plus_3,
1464 text=valid_request_text(),
1465 )
1466 ).host_request_id
1468 # Get the moderation state ID
1469 state_id = None
1470 with session_scope() as session:
1471 host_request = session.execute(
1472 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1473 ).scalar_one()
1474 state_id = host_request.moderation_state_id
1476 with real_moderation_session(super_token) as api:
1477 res = api.ModerateContent(
1478 moderation_pb2.ModerateContentReq(
1479 moderation_state_id=state_id,
1480 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1481 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1482 reason="Approved by admin",
1483 )
1484 )
1485 assert res.moderation_state.moderation_state_id == state_id
1486 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1488 # Verify state was updated in database
1489 with session_scope() as session:
1490 state = session.get_one(ModerationState, state_id)
1491 assert state.visibility == ModerationVisibility.visible
1494def test_ModerateContent_not_found(db):
1495 """Test moderating non-existent content"""
1496 super_user, super_token = generate_user(is_superuser=True)
1498 with real_moderation_session(super_token) as api:
1499 with pytest.raises(grpc.RpcError) as e:
1500 api.ModerateContent(
1501 moderation_pb2.ModerateContentReq(
1502 moderation_state_id=999999,
1503 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1504 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1505 reason="Test",
1506 )
1507 )
1508 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1509 assert e.value.details() == "Moderation state not found."
1512def test_ModerateContent_hide(db):
1513 """Test hiding content via unified moderation API"""
1514 super_user, super_token = generate_user(is_superuser=True)
1515 normal_user, user_token = generate_user()
1516 host, _ = generate_user()
1518 # Create a real host request
1519 state_id = create_test_host_request_with_moderation(user_token, host.id)
1521 with real_moderation_session(super_token) as api:
1522 res = api.ModerateContent(
1523 moderation_pb2.ModerateContentReq(
1524 moderation_state_id=state_id,
1525 action=moderation_pb2.MODERATION_ACTION_HIDE,
1526 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1527 reason="Spam content",
1528 )
1529 )
1530 assert res.moderation_state.moderation_state_id == state_id
1531 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_HIDDEN
1533 # Verify state was updated in database
1534 with session_scope() as session:
1535 state = session.get_one(ModerationState, state_id)
1536 assert state.visibility == ModerationVisibility.hidden
1539def test_ModerateContent_shadow(db):
1540 """Test shadowing content via unified moderation API"""
1541 super_user, super_token = generate_user(is_superuser=True)
1542 normal_user, user_token = generate_user()
1543 host, _ = generate_user()
1545 # Create a real host request
1546 state_id = create_test_host_request_with_moderation(user_token, host.id)
1548 with real_moderation_session(super_token) as api:
1549 res = api.ModerateContent(
1550 moderation_pb2.ModerateContentReq(
1551 moderation_state_id=state_id,
1552 action=moderation_pb2.MODERATION_ACTION_HIDE,
1553 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
1554 reason="Needs further review",
1555 )
1556 )
1557 assert res.moderation_state.moderation_state_id == state_id
1558 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1560 # Verify state was updated in database
1561 with session_scope() as session:
1562 state = session.get_one(ModerationState, state_id)
1563 assert state.visibility == ModerationVisibility.shadowed
1566def test_ModerateContent_flag(db):
1567 """Test opening a flag via the FLAG action"""
1568 super_user, super_token = generate_user(is_superuser=True)
1569 user1, token1 = generate_user()
1570 user2, _ = generate_user()
1572 # Create a host request
1573 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1574 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1576 with requests_session(token1) as api:
1577 host_request_id = api.CreateHostRequest(
1578 requests_pb2.CreateHostRequestReq(
1579 host_user_id=user2.id,
1580 from_date=today_plus_2,
1581 to_date=today_plus_3,
1582 text=valid_request_text(),
1583 )
1584 ).host_request_id
1586 # Get the moderation state ID
1587 with session_scope() as session:
1588 host_request = session.execute(
1589 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1590 ).scalar_one()
1591 state_id = host_request.moderation_state_id
1593 with real_moderation_session(super_token) as api:
1594 api.ModerateContent(
1595 moderation_pb2.ModerateContentReq(
1596 moderation_state_id=state_id,
1597 action=moderation_pb2.MODERATION_ACTION_FLAG,
1598 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
1599 reason="Admin flagged for additional review",
1600 priority=5,
1601 )
1602 )
1604 # Verify queue item was created in database with the given priority, plus a log row referencing it
1605 with session_scope() as session:
1606 queue_item = session.execute(
1607 select(ModerationQueueItem)
1608 .where(ModerationQueueItem.moderation_state_id == state_id)
1609 .where(ModerationQueueItem.trigger == ModerationTrigger.moderator_review)
1610 ).scalar_one()
1611 assert queue_item.resolved_by_log_id is None
1612 assert queue_item.priority == 5
1614 flag_log = session.execute(
1615 select(ModerationLog)
1616 .where(ModerationLog.moderation_state_id == state_id)
1617 .where(ModerationLog.action == ModerationAction.flag)
1618 ).scalar_one()
1619 assert flag_log.queue_item_id == queue_item.id
1622def test_ModerateContent_flag_requires_trigger(db):
1623 """FLAG without a trigger is rejected."""
1624 super_user, super_token = generate_user(is_superuser=True)
1625 user1, token1 = generate_user()
1626 user2, _ = generate_user()
1627 state_id = create_test_host_request_with_moderation(token1, user2.id)
1629 with real_moderation_session(super_token) as api:
1630 with pytest.raises(grpc.RpcError) as e:
1631 api.ModerateContent(
1632 moderation_pb2.ModerateContentReq(
1633 moderation_state_id=state_id,
1634 action=moderation_pb2.MODERATION_ACTION_FLAG,
1635 reason="no trigger",
1636 )
1637 )
1638 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1641def _open_queue_item_id(state_id):
1642 with session_scope() as session:
1643 return (
1644 session.execute(
1645 select(ModerationQueueItem)
1646 .where(ModerationQueueItem.moderation_state_id == state_id)
1647 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1648 )
1649 .scalars()
1650 .one()
1651 .id
1652 )
1655def test_ModerateContent_set_priority(db):
1656 """SET_PRIORITY changes a flag's priority and logs it, without touching visibility."""
1657 moderator, mod_token = generate_user(is_superuser=True)
1658 user1, token1 = generate_user()
1659 user2, _ = generate_user()
1660 state_id = create_test_host_request_with_moderation(token1, user2.id)
1661 queue_item_id = _open_queue_item_id(state_id)
1663 with real_moderation_session(mod_token) as api:
1664 api.ModerateContent(
1665 moderation_pb2.ModerateContentReq(
1666 moderation_state_id=state_id,
1667 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY,
1668 queue_item_id=queue_item_id,
1669 priority=10,
1670 reason="bump",
1671 )
1672 )
1674 with session_scope() as session:
1675 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
1676 assert queue_item.priority == 10
1677 assert queue_item.resolved_by_log_id is None # not resolved
1678 # State stays shadowed (visibility untouched)
1679 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.shadowed
1681 log = session.execute(
1682 select(ModerationLog)
1683 .where(ModerationLog.moderation_state_id == state_id)
1684 .where(ModerationLog.action == ModerationAction.set_priority)
1685 ).scalar_one()
1686 assert log.new_priority == 10
1687 assert log.queue_item_id == queue_item_id
1689 # GetModerationQueue surfaces the new priority
1690 with real_moderation_session(mod_token) as api:
1691 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1692 item = next(i for i in res.queue_items if i.queue_item_id == queue_item_id)
1693 assert item.priority == 10
1696def test_ModerateContent_unflag(db):
1697 """UNFLAG resolves a single named flag and does not change visibility."""
1698 moderator, mod_token = generate_user(is_superuser=True)
1699 user1, token1 = generate_user()
1700 user2, _ = generate_user()
1701 state_id = create_test_host_request_with_moderation(token1, user2.id)
1702 queue_item_id = _open_queue_item_id(state_id)
1704 with real_moderation_session(mod_token) as api:
1705 api.ModerateContent(
1706 moderation_pb2.ModerateContentReq(
1707 moderation_state_id=state_id,
1708 action=moderation_pb2.MODERATION_ACTION_UNFLAG,
1709 queue_item_id=queue_item_id,
1710 reason="dismissed",
1711 )
1712 )
1714 with session_scope() as session:
1715 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
1716 assert queue_item.resolved_by_log_id is not None
1717 # Visibility unchanged (still shadowed from creation)
1718 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.shadowed
1719 log = session.get_one(ModerationLog, queue_item.resolved_by_log_id)
1720 assert log.action == ModerationAction.unflag
1721 assert log.queue_item_id == queue_item_id
1724def test_ModerateContent_unflag_requires_queue_item(db):
1725 """UNFLAG without a queue_item_id is rejected."""
1726 moderator, mod_token = generate_user(is_superuser=True)
1727 user1, token1 = generate_user()
1728 user2, _ = generate_user()
1729 state_id = create_test_host_request_with_moderation(token1, user2.id)
1731 with real_moderation_session(mod_token) as api:
1732 with pytest.raises(grpc.RpcError) as e:
1733 api.ModerateContent(
1734 moderation_pb2.ModerateContentReq(
1735 moderation_state_id=state_id,
1736 action=moderation_pb2.MODERATION_ACTION_UNFLAG,
1737 reason="no target",
1738 )
1739 )
1740 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1743def test_ModerateContent_approve_without_clear_flags_leaves_flag_open(db):
1744 """APPROVE without clear_flags changes visibility but leaves open flags in the queue."""
1745 moderator, mod_token = generate_user(is_superuser=True)
1746 user1, token1 = generate_user()
1747 user2, _ = generate_user()
1748 state_id = create_test_host_request_with_moderation(token1, user2.id)
1749 queue_item_id = _open_queue_item_id(state_id)
1751 with real_moderation_session(mod_token) as api:
1752 api.ModerateContent(
1753 moderation_pb2.ModerateContentReq(
1754 moderation_state_id=state_id,
1755 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1756 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1757 reason="visible but still under review",
1758 )
1759 )
1761 with session_scope() as session:
1762 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.visible
1763 assert session.get_one(ModerationQueueItem, queue_item_id).resolved_by_log_id is None
1766def test_ModerateContent_flag_supersede(db):
1767 """FLAG with supersede_queue_item_id resolves the named flag as the new one opens."""
1768 moderator, mod_token = generate_user(is_superuser=True)
1769 user1, token1 = generate_user()
1770 user2, _ = generate_user()
1771 state_id = create_test_host_request_with_moderation(token1, user2.id)
1772 initial_item_id = _open_queue_item_id(state_id)
1774 with real_moderation_session(mod_token) as api:
1775 api.ModerateContent(
1776 moderation_pb2.ModerateContentReq(
1777 moderation_state_id=state_id,
1778 action=moderation_pb2.MODERATION_ACTION_FLAG,
1779 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG,
1780 priority=3,
1781 supersede_queue_item_id=initial_item_id,
1782 reason="machine re-flag",
1783 )
1784 )
1786 with session_scope() as session:
1787 # Old initial_review flag is resolved
1788 assert session.get_one(ModerationQueueItem, initial_item_id).resolved_by_log_id is not None
1789 # Exactly one open flag remains: the new machine_flag at priority 3
1790 open_items = (
1791 session.execute(
1792 select(ModerationQueueItem)
1793 .where(ModerationQueueItem.moderation_state_id == state_id)
1794 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1795 )
1796 .scalars()
1797 .all()
1798 )
1799 assert len(open_items) == 1
1800 assert open_items[0].trigger == ModerationTrigger.machine_flag
1801 assert open_items[0].priority == 3
1804def test_GetModerationQueue_filter_by_priority(db):
1805 """priority_min / priority_max filter the queue by priority range."""
1806 moderator, mod_token = generate_user(is_superuser=True)
1807 user1, token1 = generate_user()
1808 user2, _ = generate_user()
1809 state_id = create_test_host_request_with_moderation(token1, user2.id)
1810 queue_item_id = _open_queue_item_id(state_id)
1812 with real_moderation_session(mod_token) as api:
1813 api.ModerateContent(
1814 moderation_pb2.ModerateContentReq(
1815 moderation_state_id=state_id,
1816 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY,
1817 queue_item_id=queue_item_id,
1818 priority=7,
1819 reason="raise",
1820 )
1821 )
1823 # priority_min excludes it below the threshold, includes at/above
1824 assert len(api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_min=8)).queue_items) == 0
1825 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_min=7))
1826 assert [i.queue_item_id for i in res.queue_items] == [queue_item_id]
1828 # priority_max excludes it above the threshold
1829 assert len(api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_max=6)).queue_items) == 0
1830 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_max=7))
1831 assert [i.queue_item_id for i in res.queue_items] == [queue_item_id]
1834# ============================================================================
1835# Tests for group chat moderation
1836# ============================================================================
1839def test_group_chat_created_with_moderation_state(db):
1840 """Test that group chats are created with moderation state"""
1841 user1, token1 = generate_user()
1842 user2, _ = generate_user()
1843 make_friends(user1, user2)
1845 with conversations_session(token1) as api:
1846 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1847 group_chat_id = res.group_chat_id
1849 # Verify moderation state was created
1850 with session_scope() as session:
1851 group_chat = session.execute(select(GroupChat).where(GroupChat.conversation_id == group_chat_id)).scalar_one()
1853 assert group_chat.moderation_state.object_type == ModerationObjectType.group_chat
1854 assert group_chat.moderation_state.object_id == group_chat_id
1855 # Group chats start as SHADOWED
1856 assert group_chat.moderation_state.visibility == ModerationVisibility.shadowed
1858 # A moderation queue item should have been created
1859 queue_item = (
1860 session.execute(
1861 select(ModerationQueueItem).where(
1862 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id
1863 )
1864 )
1865 .scalars()
1866 .first()
1867 )
1868 assert queue_item is not None
1869 assert queue_item.trigger == ModerationTrigger.initial_review
1872def test_group_chat_GetModerationState(db):
1873 """Test GetModerationState API for group chats"""
1874 user1, token1 = generate_user()
1875 user2, _ = generate_user()
1876 moderator, mod_token = generate_user(is_superuser=True)
1877 make_friends(user1, user2)
1879 with conversations_session(token1) as api:
1880 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1881 group_chat_id = res.group_chat_id
1883 # Moderator can look up the moderation state
1884 with real_moderation_session(mod_token) as api:
1885 res = api.GetModerationState(
1886 moderation_pb2.GetModerationStateReq(
1887 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1888 object_id=group_chat_id,
1889 )
1890 )
1891 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT
1892 assert res.moderation_state.object_id == group_chat_id
1893 # Starts as SHADOWED
1894 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1897def test_group_chat_moderation_hide(db):
1898 """Test that a moderator can hide a group chat and participants can no longer see it"""
1899 user1, token1 = generate_user()
1900 user2, token2 = generate_user()
1901 moderator, mod_token = generate_user(is_superuser=True)
1902 make_friends(user1, user2)
1904 with conversations_session(token1) as api:
1905 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1906 group_chat_id = res.group_chat_id
1907 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
1909 # First approve the group chat so both users can see it
1910 with real_moderation_session(mod_token) as api:
1911 state_res = api.GetModerationState(
1912 moderation_pb2.GetModerationStateReq(
1913 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1914 object_id=group_chat_id,
1915 )
1916 )
1917 api.ModerateContent(
1918 moderation_pb2.ModerateContentReq(
1919 moderation_state_id=state_res.moderation_state.moderation_state_id,
1920 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1921 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1922 reason="Approved",
1923 )
1924 )
1926 # Both users can see the chat now
1927 with conversations_session(token1) as api:
1928 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1929 assert len(res.group_chats) == 1
1931 with conversations_session(token2) as api:
1932 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1933 assert len(res.group_chats) == 1
1935 # Moderator hides the group chat
1936 with real_moderation_session(mod_token) as api:
1937 state_res = api.GetModerationState(
1938 moderation_pb2.GetModerationStateReq(
1939 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1940 object_id=group_chat_id,
1941 )
1942 )
1943 api.ModerateContent(
1944 moderation_pb2.ModerateContentReq(
1945 moderation_state_id=state_res.moderation_state.moderation_state_id,
1946 action=moderation_pb2.MODERATION_ACTION_HIDE,
1947 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1948 reason="Inappropriate content",
1949 )
1950 )
1952 # Neither user can see the chat now
1953 with conversations_session(token1) as api:
1954 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1955 assert len(res.group_chats) == 0
1957 with conversations_session(token2) as api:
1958 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1959 assert len(res.group_chats) == 0
1961 # Trying to get messages returns empty (chat is hidden so no messages visible)
1962 with conversations_session(token1) as api:
1963 res = api.GetGroupChatMessages(conversations_pb2.GetGroupChatMessagesReq(group_chat_id=group_chat_id))
1964 assert len(res.messages) == 0
1967def test_group_chat_moderation_shadow(db):
1968 """Test that shadowing a group chat hides it from non-creator participants"""
1969 user1, token1 = generate_user() # Creator
1970 user2, token2 = generate_user() # Participant
1971 moderator, mod_token = generate_user(is_superuser=True)
1972 make_friends(user1, user2)
1974 with conversations_session(token1) as api:
1975 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1976 group_chat_id = res.group_chat_id
1977 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
1979 # Moderator shadows the group chat
1980 with real_moderation_session(mod_token) as api:
1981 state_res = api.GetModerationState(
1982 moderation_pb2.GetModerationStateReq(
1983 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1984 object_id=group_chat_id,
1985 )
1986 )
1987 api.ModerateContent(
1988 moderation_pb2.ModerateContentReq(
1989 moderation_state_id=state_res.moderation_state.moderation_state_id,
1990 action=moderation_pb2.MODERATION_ACTION_HIDE,
1991 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
1992 reason="Needs review",
1993 )
1994 )
1996 # Creator can see SHADOWED content in list operations
1997 with conversations_session(token1) as api:
1998 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1999 assert len(res.group_chats) == 1
2000 assert res.group_chats[0].group_chat_id == group_chat_id
2002 # But non-creator participant cannot see it in lists
2003 with conversations_session(token2) as api:
2004 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2005 assert len(res.group_chats) == 0
2007 # Creator can also access it directly via GetGroupChat
2008 with conversations_session(token1) as api:
2009 res = api.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id))
2010 assert res.group_chat_id == group_chat_id
2013# ============================================================================
2014# Tests for auto-approval background job
2015# ============================================================================
2018def test_auto_approve_moderation_queue_disabled_when_zero(db, email_collector: EmailCollector):
2019 """Test that auto-approval is disabled when deadline is 0"""
2020 moderator, mod_token = generate_user(is_superuser=True)
2021 user1, token1 = generate_user()
2022 user2, token2 = generate_user()
2024 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2025 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2027 # Create a host request
2028 with requests_session(token1) as api:
2029 host_request_id = api.CreateHostRequest(
2030 requests_pb2.CreateHostRequestReq(
2031 host_user_id=user2.id,
2032 from_date=today_plus_2,
2033 to_date=today_plus_3,
2034 text=valid_request_text(),
2035 )
2036 ).host_request_id
2038 # No email should have been sent (request is shadowed)
2039 assert email_collector.count_for_recipient(user2.email) == 0
2041 # Ensure deadline is 0 (disabled)
2042 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 0
2044 # Run the job
2045 auto_approve_moderation_queue(empty_pb2.Empty())
2047 # Surfer (author) can see the request via API
2048 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2049 assert res.host_request_id == host_request_id
2051 # Author can see their SHADOWED request in their sent list
2052 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
2053 assert len(res.host_requests) == 1
2054 assert res.host_requests[0].host_request_id == host_request_id
2056 # Host cannot see the request (it's shadowed from them)
2057 with requests_session(token2) as api:
2058 with pytest.raises(grpc.RpcError) as e:
2059 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2060 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2062 # Host doesn't see it in their received list either
2063 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2064 assert len(res.host_requests) == 0
2066 # Moderator can still see the item in the moderation queue
2067 with real_moderation_session(mod_token) as api:
2068 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2069 assert len(res.queue_items) == 1
2070 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW
2072 # Moderator can check the state is still SHADOWED
2073 state_res = api.GetModerationState(
2074 moderation_pb2.GetModerationStateReq(
2075 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2076 object_id=host_request_id,
2077 )
2078 )
2079 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2082def test_auto_approve_moderation_queue_approves_old_items(
2083 db, email_collector: EmailCollector, push_collector: PushCollector
2084):
2085 """Test that auto-approval approves items older than the deadline"""
2086 moderator, mod_token = generate_user(is_superuser=True)
2087 user1, token1 = generate_user()
2088 user2, token2 = generate_user()
2090 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2091 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2093 # Create a host request
2094 with requests_session(token1) as api:
2095 host_request_id = api.CreateHostRequest(
2096 requests_pb2.CreateHostRequestReq(
2097 host_user_id=user2.id,
2098 from_date=today_plus_2,
2099 to_date=today_plus_3,
2100 text=valid_request_text("Test request for auto-approval"),
2101 )
2102 ).host_request_id
2104 # No email sent initially (shadowed)
2105 assert email_collector.count_for_recipient(user2.email) == 0
2107 # Host cannot see the request yet
2108 with requests_session(token2) as api:
2109 with pytest.raises(grpc.RpcError) as e:
2110 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2111 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2113 # Make the queue item appear old by backdating its time_created
2114 with session_scope() as session:
2115 host_request = session.execute(
2116 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
2117 ).scalar_one()
2118 queue_item = session.execute(
2119 select(ModerationQueueItem)
2120 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
2121 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2122 ).scalar_one()
2123 # Backdate the queue item by 2 minutes
2124 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=2)
2126 # Set deadline to 60 seconds (items older than 60 seconds will be auto-approved)
2127 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2128 config.MODERATION_BOT_USER_ID = moderator.id
2130 # Run the job
2131 auto_approve_moderation_queue(empty_pb2.Empty())
2133 # Now host can see the request via API
2134 with requests_session(token2) as api:
2135 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2136 assert res.host_request_id == host_request_id
2137 assert res.latest_message.text.text == valid_request_text("Test request for auto-approval")
2139 # Host sees it in their received list
2140 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2141 assert len(res.host_requests) == 1
2142 assert res.host_requests[0].host_request_id == host_request_id
2144 # Surfer sees it in their sent list
2145 with requests_session(token1) as api:
2146 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
2147 assert len(res.host_requests) == 1
2148 assert res.host_requests[0].host_request_id == host_request_id
2150 with real_moderation_session(mod_token) as api:
2151 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2152 assert len(res.queue_items) == 1
2153 flag_item = res.queue_items[0]
2154 assert flag_item.trigger == moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG
2155 assert flag_item.priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY
2157 state_res = api.GetModerationState(
2158 moderation_pb2.GetModerationStateReq(
2159 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2160 object_id=host_request_id,
2161 )
2162 )
2163 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
2165 log_res = api.GetModerationLog(
2166 moderation_pb2.GetModerationLogReq(moderation_state_id=state_res.moderation_state.moderation_state_id)
2167 )
2168 approve_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_APPROVE]
2169 assert len(approve_entries) == 1
2170 assert "Auto-approved" in approve_entries[0].reason
2171 assert "60 seconds" in approve_entries[0].reason
2172 assert approve_entries[0].moderator_user_id == moderator.id
2174 flag_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_FLAG]
2175 assert len(flag_entries) == 1
2176 assert flag_entries[0].moderator_user_id == moderator.id
2179def test_auto_approve_does_not_approve_recent_items(db, email_collector: EmailCollector):
2180 """Test that auto-approval does not approve items that are newer than the deadline"""
2181 moderator, mod_token = generate_user(is_superuser=True)
2182 user1, token1 = generate_user()
2183 user2, token2 = generate_user()
2185 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2186 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2188 # Create a host request
2189 with requests_session(token1) as api:
2190 host_request_id = api.CreateHostRequest(
2191 requests_pb2.CreateHostRequestReq(
2192 host_user_id=user2.id,
2193 from_date=today_plus_2,
2194 to_date=today_plus_3,
2195 text=valid_request_text(),
2196 )
2197 ).host_request_id
2199 # No email sent (shadowed)
2200 assert email_collector.count_for_recipient(user2.email) == 0
2202 # Set deadline to 1 hour (items older than 1 hour will be auto-approved)
2203 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 3600
2204 config.MODERATION_BOT_USER_ID = moderator.id
2206 # Run the job - the item was just created, so it shouldn't be approved
2207 auto_approve_moderation_queue(empty_pb2.Empty())
2209 # Still no email sent
2210 assert email_collector.count_for_recipient(user2.email) == 0
2212 # Host still cannot see the request
2213 with requests_session(token2) as api:
2214 with pytest.raises(grpc.RpcError) as e:
2215 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2216 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2218 # Not in host's received list
2219 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2220 assert len(res.host_requests) == 0
2222 # Moderator sees it still in queue unresolved
2223 with real_moderation_session(mod_token) as api:
2224 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2225 assert len(res.queue_items) == 1
2227 # State is still SHADOWED
2228 state_res = api.GetModerationState(
2229 moderation_pb2.GetModerationStateReq(
2230 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2231 object_id=host_request_id,
2232 )
2233 )
2234 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2237def test_auto_approve_does_not_approve_already_approved(db):
2238 """Test that auto-approval does not re-approve already visible content"""
2239 moderator, mod_token = generate_user(is_superuser=True)
2240 user1, token1 = generate_user()
2241 user2, token2 = generate_user()
2243 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2244 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2246 # Create a host request
2247 with requests_session(token1) as api:
2248 host_request_id = api.CreateHostRequest(
2249 requests_pb2.CreateHostRequestReq(
2250 host_user_id=user2.id,
2251 from_date=today_plus_2,
2252 to_date=today_plus_3,
2253 text=valid_request_text(),
2254 )
2255 ).host_request_id
2257 # Moderator approves it manually
2258 with real_moderation_session(mod_token) as api:
2259 state_res = api.GetModerationState(
2260 moderation_pb2.GetModerationStateReq(
2261 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2262 object_id=host_request_id,
2263 )
2264 )
2265 state_id = state_res.moderation_state.moderation_state_id
2267 api.ModerateContent(
2268 moderation_pb2.ModerateContentReq(
2269 moderation_state_id=state_id,
2270 action=moderation_pb2.MODERATION_ACTION_APPROVE,
2271 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2272 reason="Approved by moderator",
2273 clear_flags=True,
2274 )
2275 )
2277 # Host can now see it
2278 with requests_session(token2) as api:
2279 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2280 assert res.host_request_id == host_request_id
2282 # Get log count before auto-approval
2283 with real_moderation_session(mod_token) as api:
2284 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2285 log_count_before = len(log_res_before.log_entries)
2287 # Set deadline to 1 second
2288 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1
2289 config.MODERATION_BOT_USER_ID = moderator.id
2291 # Run the job
2292 auto_approve_moderation_queue(empty_pb2.Empty())
2294 # No new log entries should be created (already approved, queue item resolved)
2295 with real_moderation_session(mod_token) as api:
2296 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2297 assert len(log_res_after.log_entries) == log_count_before
2299 # Queue should be empty (item was resolved when moderator approved)
2300 queue_res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2301 assert len(queue_res.queue_items) == 0
2304def test_auto_approve_does_not_approve_moderator_shadowed_items(db):
2305 """Test that auto-approval does not approve items that were explicitly shadowed by a moderator"""
2306 moderator, mod_token = generate_user(is_superuser=True)
2307 user1, token1 = generate_user()
2308 user2, token2 = generate_user()
2310 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2311 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2313 # Create a host request
2314 with requests_session(token1) as api:
2315 host_request_id = api.CreateHostRequest(
2316 requests_pb2.CreateHostRequestReq(
2317 host_user_id=user2.id,
2318 from_date=today_plus_2,
2319 to_date=today_plus_3,
2320 text=valid_request_text(),
2321 )
2322 ).host_request_id
2324 # Moderator explicitly shadows the content (keeping it shadowed but resolving the queue item)
2325 with real_moderation_session(mod_token) as api:
2326 state_res = api.GetModerationState(
2327 moderation_pb2.GetModerationStateReq(
2328 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2329 object_id=host_request_id,
2330 )
2331 )
2332 state_id = state_res.moderation_state.moderation_state_id
2334 # Set to SHADOWED explicitly with clear_flags - this resolves the INITIAL_REVIEW queue item
2335 api.ModerateContent(
2336 moderation_pb2.ModerateContentReq(
2337 moderation_state_id=state_id,
2338 action=moderation_pb2.MODERATION_ACTION_HIDE,
2339 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2340 reason="Keeping shadowed for review",
2341 clear_flags=True,
2342 )
2343 )
2345 # Backdate to ensure it would be old enough for auto-approval
2346 with session_scope() as session:
2347 queue_item = session.execute(
2348 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
2349 ).scalar_one()
2350 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10)
2352 # Set deadline to 1 second
2353 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1
2354 config.MODERATION_BOT_USER_ID = moderator.id
2356 # Get log count before
2357 with real_moderation_session(mod_token) as api:
2358 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2359 log_count_before = len(log_res_before.log_entries)
2361 # Run the job
2362 auto_approve_moderation_queue(empty_pb2.Empty())
2364 # No new log entries - the queue item was resolved when moderator shadowed it
2365 with real_moderation_session(mod_token) as api:
2366 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2367 assert len(log_res_after.log_entries) == log_count_before
2369 # State should still be SHADOWED (not auto-approved to VISIBLE)
2370 state_res = api.GetModerationState(
2371 moderation_pb2.GetModerationStateReq(
2372 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2373 object_id=host_request_id,
2374 )
2375 )
2376 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2378 # Host still cannot see the request
2379 with requests_session(token2) as api:
2380 with pytest.raises(grpc.RpcError) as e:
2381 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2382 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2385def test_auto_approve_skips_shadowed_user_authored_items(db):
2386 """Auto-approval must not promote content authored by a currently-shadowed user."""
2387 moderator, mod_token = generate_user(is_superuser=True)
2388 surfer, surfer_token = generate_user()
2389 host, host_token = generate_user()
2391 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2392 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2394 with requests_session(surfer_token) as api:
2395 host_request_id = api.CreateHostRequest(
2396 requests_pb2.CreateHostRequestReq(
2397 host_user_id=host.id,
2398 from_date=today_plus_2,
2399 to_date=today_plus_3,
2400 text=valid_request_text(),
2401 )
2402 ).host_request_id
2404 # Shadow the surfer after the host request was created
2405 with session_scope() as session:
2406 session.execute(select(User).where(User.id == surfer.id)).scalar_one().shadowed_at = now()
2408 # Backdate the queue item to make it eligible for auto-approval
2409 with session_scope() as session:
2410 host_request = session.execute(
2411 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
2412 ).scalar_one()
2413 queue_item = session.execute(
2414 select(ModerationQueueItem)
2415 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
2416 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2417 ).scalar_one()
2418 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10)
2420 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2421 config.MODERATION_BOT_USER_ID = moderator.id
2423 auto_approve_moderation_queue(empty_pb2.Empty())
2425 # State should remain SHADOWED — the auto-approve job must skip shadowed-user content
2426 with real_moderation_session(mod_token) as api:
2427 state_res = api.GetModerationState(
2428 moderation_pb2.GetModerationStateReq(
2429 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2430 object_id=host_request_id,
2431 )
2432 )
2433 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2435 # Host still cannot see the request
2436 with requests_session(host_token) as api:
2437 with pytest.raises(grpc.RpcError) as e:
2438 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2439 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2441 # But the (shadowed) author can still see their own request
2442 with requests_session(surfer_token) as api:
2443 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2444 assert res.host_request_id == host_request_id
2447def test_auto_approve_preserves_other_open_flags(db):
2448 """Auto-approval supersedes only the INITIAL_REVIEW item; other open flags survive and a high-prio flag is added."""
2449 moderator, mod_token = generate_user(is_superuser=True)
2450 surfer, surfer_token = generate_user()
2451 host, _ = generate_user()
2453 state_id = create_test_host_request_with_moderation(surfer_token, host.id)
2455 with real_moderation_session(mod_token) as api:
2456 api.ModerateContent(
2457 moderation_pb2.ModerateContentReq(
2458 moderation_state_id=state_id,
2459 action=moderation_pb2.MODERATION_ACTION_FLAG,
2460 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
2461 priority=5,
2462 reason="user reported this",
2463 )
2464 )
2466 with session_scope() as session:
2467 initial_item = session.execute(
2468 select(ModerationQueueItem)
2469 .where(ModerationQueueItem.moderation_state_id == state_id)
2470 .where(ModerationQueueItem.trigger == ModerationTrigger.initial_review)
2471 ).scalar_one()
2472 initial_item_id = initial_item.id
2473 initial_item.time_created = datetime.now(initial_item.time_created.tzinfo) - timedelta(minutes=10)
2475 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2476 config.MODERATION_BOT_USER_ID = moderator.id
2478 auto_approve_moderation_queue(empty_pb2.Empty())
2480 with session_scope() as session:
2481 assert (
2482 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.id == initial_item_id))
2483 .scalar_one()
2484 .resolved_by_log_id
2485 is not None
2486 )
2487 assert (
2488 session.execute(select(ModerationState).where(ModerationState.id == state_id)).scalar_one().visibility
2489 == ModerationVisibility.visible
2490 )
2492 open_items = (
2493 session.execute(
2494 select(ModerationQueueItem)
2495 .where(ModerationQueueItem.moderation_state_id == state_id)
2496 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2497 )
2498 .scalars()
2499 .all()
2500 )
2501 open_by_trigger = {item.trigger: item for item in open_items}
2502 assert open_by_trigger.keys() == {ModerationTrigger.user_flag, ModerationTrigger.machine_flag}
2503 assert open_by_trigger[ModerationTrigger.user_flag].priority == 5
2504 assert open_by_trigger[ModerationTrigger.machine_flag].priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY
2507# ============================================================================
2508# Notification Suppression Tests
2509# ============================================================================
2512def test_host_request_message_notifications_suppressed_before_approval(
2513 db, email_collector: EmailCollector, push_collector: PushCollector, moderator
2514):
2515 """
2516 Test that notifications are NOT sent for messages in host requests
2517 that haven't been approved yet.
2518 """
2519 host, host_token = generate_user(complete_profile=True)
2520 surfer, surfer_token = generate_user(complete_profile=True)
2522 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2523 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2525 # Create host request (it starts in SHADOWED state)
2526 with requests_session(surfer_token) as api:
2527 hr_id = api.CreateHostRequest(
2528 requests_pb2.CreateHostRequestReq(
2529 host_user_id=host.id,
2530 from_date=today_plus_2,
2531 to_date=today_plus_3,
2532 text=valid_request_text("Initial request message"),
2533 )
2534 ).host_request_id
2536 # No notifications should have been sent to the host (request is SHADOWED)
2537 assert email_collector.count_for_recipient(host.email) == 0
2538 assert push_collector.count_for_user(host.id) == 0
2540 # Send additional messages BEFORE approval - should NOT generate notifications
2541 with requests_session(surfer_token) as api:
2542 api.SendHostRequestMessage(
2543 requests_pb2.SendHostRequestMessageReq(
2544 host_request_id=hr_id,
2545 text="Follow-up message 1",
2546 )
2547 )
2548 api.SendHostRequestMessage(
2549 requests_pb2.SendHostRequestMessageReq(
2550 host_request_id=hr_id,
2551 text="Follow-up message 2",
2552 )
2553 )
2555 # Host should STILL have no notifications (messages sent while SHADOWED)
2556 assert email_collector.count_for_recipient(host.email) == 0
2557 assert push_collector.count_for_user(host.id) == 0
2559 # Now approve the request
2560 moderator.approve_host_request(hr_id)
2561 email_collector.pop_for_recipient(host.email)
2563 # Host should now have 3 notifications (all deferred notifications are delivered on approval):
2564 # 1. host_request:create (the initial request)
2565 # 2. host_request:message (Follow-up message 1)
2566 # 3. host_request:message (Follow-up message 2)
2567 assert push_collector.count_for_user(host.id) == 3
2568 push = push_collector.pop_for_user(host.id, last=False)
2569 assert push.content.title == f"New host request from {surfer.name}"
2572def test_host_request_status_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2573 """
2574 Test that status change notifications (accept/reject/etc.) are NOT sent
2575 for host requests that haven't been approved yet.
2577 Note: In practice, the host can't even SEE the request to accept/reject it
2578 when it's SHADOWED. But if they somehow did, we still shouldn't notify.
2579 """
2580 host, host_token = generate_user(complete_profile=True)
2581 surfer, surfer_token = generate_user(complete_profile=True)
2583 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2584 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2586 # Create host request
2587 with requests_session(surfer_token) as api:
2588 hr_id = api.CreateHostRequest(
2589 requests_pb2.CreateHostRequestReq(
2590 host_user_id=host.id,
2591 from_date=today_plus_2,
2592 to_date=today_plus_3,
2593 text=valid_request_text(),
2594 )
2595 ).host_request_id
2597 # No notifications should have been sent to the host (request is SHADOWED)
2598 assert push_collector.count_for_user(host.id) == 0
2600 # The surfer can cancel their own request even when SHADOWED
2601 # But this should NOT notify the host since the request isn't approved
2602 with requests_session(surfer_token) as api:
2603 api.RespondHostRequest(
2604 requests_pb2.RespondHostRequestReq(
2605 host_request_id=hr_id,
2606 status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED,
2607 text="Actually, never mind",
2608 )
2609 )
2611 # Host should STILL have no notifications (cancel notification suppressed)
2612 assert push_collector.count_for_user(host.id) == 0
2615def test_host_request_notifications_sent_after_approval(
2616 db, email_collector: EmailCollector, push_collector: PushCollector, moderator
2617):
2618 """
2619 Test that after a host request is approved, all notifications work normally.
2620 """
2621 host, host_token = generate_user(complete_profile=True)
2622 surfer, surfer_token = generate_user(complete_profile=True)
2624 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2625 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2627 # Create and approve host request
2628 with requests_session(surfer_token) as api:
2629 hr_id = api.CreateHostRequest(
2630 requests_pb2.CreateHostRequestReq(
2631 host_user_id=host.id,
2632 from_date=today_plus_2,
2633 to_date=today_plus_3,
2634 text=valid_request_text(),
2635 )
2636 ).host_request_id
2638 moderator.approve_host_request(hr_id)
2640 # Host should have received 1 notification (the approval notification)
2641 email_collector.pop_for_recipient(host.email, last=True)
2642 push_collector.pop_for_user(host.id, last=True)
2644 # Host accepts the request - surfer should be notified
2645 with requests_session(host_token) as api:
2646 api.RespondHostRequest(
2647 requests_pb2.RespondHostRequestReq(
2648 host_request_id=hr_id,
2649 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
2650 text="Sure, come on over!",
2651 )
2652 )
2654 # Surfer should have 1 notification (the accept notification)
2655 email_collector.pop_for_recipient(surfer.email, last=True)
2656 push = push_collector.pop_for_user(surfer.id, last=True)
2657 assert push.content.title == f"{host.name} accepted your host request"
2659 # Surfer confirms - host should be notified
2660 with requests_session(surfer_token) as api:
2661 api.RespondHostRequest(
2662 requests_pb2.RespondHostRequestReq(
2663 host_request_id=hr_id,
2664 status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED,
2665 text="See you then!",
2666 )
2667 )
2669 # Host should now have received the confirmation notifications
2670 email_collector.pop_for_recipient(host.email, last=True)
2671 push = push_collector.pop_for_user(host.id, last=True)
2672 assert push.content.title == f"{surfer.name} confirmed their host request"
2675def test_group_chat_message_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2676 """
2677 Test that notifications are NOT sent for messages in group chats
2678 that haven't been approved yet.
2679 """
2680 user1, token1 = generate_user(complete_profile=True)
2681 user2, token2 = generate_user(complete_profile=True)
2683 # Create a group chat (starts in SHADOWED state)
2684 with conversations_session(token1) as api:
2685 res = api.CreateGroupChat(
2686 conversations_pb2.CreateGroupChatReq(
2687 recipient_user_ids=[user2.id],
2688 )
2689 )
2690 gc_id = res.group_chat_id
2692 # Verify initial state
2693 with session_scope() as session:
2694 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2695 assert gc.moderation_state.visibility == ModerationVisibility.shadowed
2697 # No notifications should have been sent yet (chat is SHADOWED)
2698 assert push_collector.count_for_user(user2.id) == 0
2700 # Send messages BEFORE approval
2701 with conversations_session(token1) as api:
2702 api.SendMessage(
2703 conversations_pb2.SendMessageReq(
2704 group_chat_id=gc_id,
2705 text="Hello before approval",
2706 )
2707 )
2709 # Process the queued notification job
2710 while process_job():
2711 pass
2713 # User2 should STILL have no notifications (chat is SHADOWED)
2714 assert push_collector.count_for_user(user2.id) == 0
2716 # Now approve the group chat
2717 moderator.approve_group_chat(gc_id)
2719 # Process the queued notification jobs from approval
2720 while process_job():
2721 pass
2723 # Verify moderation state after approval
2724 with session_scope() as session:
2725 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2726 assert gc.moderation_state.visibility == ModerationVisibility.visible
2728 # User2 should have received 1 notification for the first message sent before approval
2729 push = push_collector.pop_for_user(user2.id, last=True)
2730 assert push.content.title == user1.name
2731 assert push.content.body == "Hello before approval"
2733 # Send a message AFTER approval
2734 with conversations_session(token1) as api:
2735 api.SendMessage(
2736 conversations_pb2.SendMessageReq(
2737 group_chat_id=gc_id,
2738 text="Hello after approval",
2739 )
2740 )
2742 # Process the queued notification job
2743 while process_job():
2744 pass
2746 # User2 should have received another notification
2747 assert push_collector.count_for_user(user2.id) == 1
2750def test_event_moderation_state_content(db):
2751 """Test that event moderation state content includes both title and description"""
2752 super_user, super_token = generate_user(is_superuser=True)
2753 user, token = generate_user()
2755 with session_scope() as session:
2756 create_community(session, 0, 2, "Community", [user], [], None)
2758 start_time = now() + timedelta(hours=2)
2759 end_time = start_time + timedelta(hours=3)
2761 with events_session(token) as api:
2762 res = api.CreateEvent(
2763 events_pb2.CreateEventReq(
2764 title="My Event Title",
2765 content="My event description.",
2766 photo_key=None,
2767 offline_information=events_pb2.OfflineEventInformation(
2768 address="Near Null Island",
2769 lat=0.1,
2770 lng=0.2,
2771 ),
2772 start_time=Timestamp_from_datetime(start_time),
2773 end_time=Timestamp_from_datetime(end_time),
2774 timezone="UTC",
2775 )
2776 )
2777 event_id = res.event_id
2779 with session_scope() as session:
2780 occurrence = session.execute(select(EventOccurrence).where(EventOccurrence.id == event_id)).scalar_one()
2781 state_id = occurrence.moderation_state_id
2783 with real_moderation_session(super_token) as api:
2784 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
2785 event_items = [
2786 item
2787 for item in res.queue_items
2788 if item.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE
2789 ]
2790 assert len(event_items) == 1
2791 assert event_items[0].moderation_state.content == "My Event Title\n\nMy event description."
2794# ============================================================================
2795# Tests for SetUserContentVisibility
2796# ============================================================================
2799def _get_moderation_state(session, object_type, object_id):
2800 return session.execute(
2801 select(ModerationState)
2802 .where(ModerationState.object_type == object_type)
2803 .where(ModerationState.object_id == object_id)
2804 ).scalar_one()
2807def test_SetUserContentVisibility_host_request(db):
2808 super_user, super_token = generate_user(is_superuser=True)
2809 surfer, surfer_token = generate_user()
2810 host, _ = generate_user()
2812 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2813 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2814 with requests_session(surfer_token) as api:
2815 hr_id = api.CreateHostRequest(
2816 requests_pb2.CreateHostRequestReq(
2817 host_user_id=host.id,
2818 from_date=today_plus_2,
2819 to_date=today_plus_3,
2820 text=valid_request_text(),
2821 )
2822 ).host_request_id
2824 with real_moderation_session(super_token) as api:
2825 res = api.SetUserContentVisibility(
2826 moderation_pb2.SetUserContentVisibilityReq(
2827 user_id=surfer.id,
2828 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2829 )
2830 )
2831 # Already shadowed by default — no-op
2832 assert res.updated_count == 0
2834 with real_moderation_session(super_token) as api:
2835 res = api.SetUserContentVisibility(
2836 moderation_pb2.SetUserContentVisibilityReq(
2837 user_id=surfer.id,
2838 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2839 reason="policy violation",
2840 )
2841 )
2842 assert res.updated_count == 1
2844 with session_scope() as session:
2845 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
2846 assert state.visibility == ModerationVisibility.hidden
2848 log_entries = (
2849 session.execute(
2850 select(ModerationLog)
2851 .where(ModerationLog.moderation_state_id == state.id)
2852 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc())
2853 )
2854 .scalars()
2855 .all()
2856 )
2857 # create log + bulk update log
2858 assert len(log_entries) == 2
2859 assert log_entries[-1].new_visibility == ModerationVisibility.hidden
2860 assert log_entries[-1].moderator_user_id == super_user.id
2861 assert log_entries[-1].reason == "policy violation"
2864def test_SetUserContentVisibility_group_chat(db):
2865 super_user, super_token = generate_user(is_superuser=True)
2866 creator, creator_token = generate_user()
2867 other, _ = generate_user()
2868 make_friends(creator, other)
2870 with conversations_session(creator_token) as api:
2871 gc_id = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[other.id])).group_chat_id
2873 with real_moderation_session(super_token) as api:
2874 api.SetUserContentVisibility(
2875 moderation_pb2.SetUserContentVisibilityReq(
2876 user_id=creator.id,
2877 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2878 )
2879 )
2881 with session_scope() as session:
2882 state = _get_moderation_state(session, ModerationObjectType.group_chat, gc_id)
2883 assert state.visibility == ModerationVisibility.hidden
2886def test_SetUserContentVisibility_event_occurrence(db):
2887 super_user, super_token = generate_user(is_superuser=True)
2888 creator, creator_token = generate_user()
2890 with session_scope() as session:
2891 create_community(session, 0, 2, "Community", [creator], [], None)
2893 start_time = now() + timedelta(hours=2)
2894 end_time = start_time + timedelta(hours=3)
2895 with events_session(creator_token) as api:
2896 event_id = api.CreateEvent(
2897 events_pb2.CreateEventReq(
2898 title="Event",
2899 content="Event description.",
2900 photo_key=None,
2901 offline_information=events_pb2.OfflineEventInformation(
2902 address="Near Null Island",
2903 lat=0.1,
2904 lng=0.2,
2905 ),
2906 start_time=Timestamp_from_datetime(start_time),
2907 end_time=Timestamp_from_datetime(end_time),
2908 timezone="UTC",
2909 )
2910 ).event_id
2912 with real_moderation_session(super_token) as api:
2913 res = api.SetUserContentVisibility(
2914 moderation_pb2.SetUserContentVisibilityReq(
2915 user_id=creator.id,
2916 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2917 )
2918 )
2919 assert res.updated_count == 1
2921 with session_scope() as session:
2922 state = _get_moderation_state(session, ModerationObjectType.event_occurrence, event_id)
2923 assert state.visibility == ModerationVisibility.hidden
2926def test_SetUserContentVisibility_friend_request(db):
2927 super_user, super_token = generate_user(is_superuser=True)
2928 sender, sender_token = generate_user()
2929 recipient, _ = generate_user()
2931 with api_session(sender_token) as api:
2932 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=recipient.id))
2934 with session_scope() as session:
2935 fr_id = session.execute(
2936 select(FriendRelationship.id).where(FriendRelationship.from_user_id == sender.id)
2937 ).scalar_one()
2939 with real_moderation_session(super_token) as api:
2940 res = api.SetUserContentVisibility(
2941 moderation_pb2.SetUserContentVisibilityReq(
2942 user_id=sender.id,
2943 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2944 )
2945 )
2946 assert res.updated_count == 1
2948 with session_scope() as session:
2949 state = _get_moderation_state(session, ModerationObjectType.friend_request, fr_id)
2950 assert state.visibility == ModerationVisibility.hidden
2953def test_SetUserContentVisibility_round_trip(db):
2954 super_user, super_token = generate_user(is_superuser=True)
2955 surfer, surfer_token = generate_user()
2956 host, _ = generate_user()
2958 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2959 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2960 with requests_session(surfer_token) as api:
2961 hr_id = api.CreateHostRequest(
2962 requests_pb2.CreateHostRequestReq(
2963 host_user_id=host.id,
2964 from_date=today_plus_2,
2965 to_date=today_plus_3,
2966 text=valid_request_text(),
2967 )
2968 ).host_request_id
2970 with real_moderation_session(super_token) as api:
2971 api.SetUserContentVisibility(
2972 moderation_pb2.SetUserContentVisibilityReq(
2973 user_id=surfer.id,
2974 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2975 reason="first",
2976 )
2977 )
2978 api.SetUserContentVisibility(
2979 moderation_pb2.SetUserContentVisibilityReq(
2980 user_id=surfer.id,
2981 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2982 reason="second",
2983 )
2984 )
2986 with session_scope() as session:
2987 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
2988 assert state.visibility == ModerationVisibility.visible
2990 bulk_log_entries = (
2991 session.execute(
2992 select(ModerationLog)
2993 .where(ModerationLog.moderation_state_id == state.id)
2994 .where(ModerationLog.reason.in_(("first", "second")))
2995 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc())
2996 )
2997 .scalars()
2998 .all()
2999 )
3000 assert [entry.new_visibility for entry in bulk_log_entries] == [
3001 ModerationVisibility.hidden,
3002 ModerationVisibility.visible,
3003 ]
3006def test_SetUserContentVisibility_resolves_queue_items(db):
3007 super_user, super_token = generate_user(is_superuser=True)
3008 surfer, surfer_token = generate_user()
3009 host, _ = generate_user()
3011 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3012 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3013 with requests_session(surfer_token) as api:
3014 hr_id = api.CreateHostRequest(
3015 requests_pb2.CreateHostRequestReq(
3016 host_user_id=host.id,
3017 from_date=today_plus_2,
3018 to_date=today_plus_3,
3019 text=valid_request_text(),
3020 )
3021 ).host_request_id
3023 with session_scope() as session:
3024 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3025 queue_item = session.execute(
3026 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id)
3027 ).scalar_one()
3028 assert queue_item.resolved_by_log_id is None
3030 with real_moderation_session(super_token) as api:
3031 api.SetUserContentVisibility(
3032 moderation_pb2.SetUserContentVisibilityReq(
3033 user_id=surfer.id,
3034 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3035 )
3036 )
3038 with session_scope() as session:
3039 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3040 queue_item = session.execute(
3041 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id)
3042 ).scalar_one()
3043 assert queue_item.resolved_by_log_id is not None
3046def test_SetUserContentVisibility_noop_when_matches(db):
3047 super_user, super_token = generate_user(is_superuser=True)
3048 surfer, surfer_token = generate_user()
3049 host, _ = generate_user()
3051 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3052 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3053 with requests_session(surfer_token) as api:
3054 api.CreateHostRequest(
3055 requests_pb2.CreateHostRequestReq(
3056 host_user_id=host.id,
3057 from_date=today_plus_2,
3058 to_date=today_plus_3,
3059 text=valid_request_text(),
3060 )
3061 )
3063 with session_scope() as session:
3064 log_count_before = len(session.execute(select(ModerationLog)).scalars().all())
3066 with real_moderation_session(super_token) as api:
3067 res = api.SetUserContentVisibility(
3068 moderation_pb2.SetUserContentVisibilityReq(
3069 user_id=surfer.id,
3070 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3071 )
3072 )
3073 assert res.updated_count == 0
3075 with session_scope() as session:
3076 log_count_after = len(session.execute(select(ModerationLog)).scalars().all())
3077 assert log_count_after == log_count_before
3080def test_SetUserContentVisibility_unspecified_rejected(db):
3081 super_user, super_token = generate_user(is_superuser=True)
3082 target, _ = generate_user()
3084 with real_moderation_session(super_token) as api:
3085 with pytest.raises(grpc.RpcError) as e:
3086 api.SetUserContentVisibility(
3087 moderation_pb2.SetUserContentVisibilityReq(
3088 user_id=target.id,
3089 visibility=moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED,
3090 )
3091 )
3092 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
3095def test_SetUserContentVisibility_non_admin_rejected(db):
3096 normal_user, normal_token = generate_user()
3097 target, _ = generate_user()
3099 with real_moderation_session(normal_token) as api:
3100 with pytest.raises(grpc.RpcError) as e:
3101 api.SetUserContentVisibility(
3102 moderation_pb2.SetUserContentVisibilityReq(
3103 user_id=target.id,
3104 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3105 )
3106 )
3107 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED
3110def test_SetUserContentVisibility_writes_admin_action(db):
3111 super_user, super_token = generate_user(is_superuser=True)
3112 surfer, surfer_token = generate_user()
3113 host, _ = generate_user()
3115 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3116 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3117 with requests_session(surfer_token) as api:
3118 api.CreateHostRequest(
3119 requests_pb2.CreateHostRequestReq(
3120 host_user_id=host.id,
3121 from_date=today_plus_2,
3122 to_date=today_plus_3,
3123 text=valid_request_text(),
3124 )
3125 )
3127 with real_moderation_session(super_token) as api:
3128 api.SetUserContentVisibility(
3129 moderation_pb2.SetUserContentVisibilityReq(
3130 user_id=surfer.id,
3131 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3132 reason="bulk hide",
3133 )
3134 )
3136 with session_scope() as session:
3137 actions = (
3138 session.execute(
3139 select(AdminAction)
3140 .where(AdminAction.target_user_id == surfer.id)
3141 .where(AdminAction.action_type == "set_user_content_visibility")
3142 )
3143 .scalars()
3144 .all()
3145 )
3146 assert len(actions) == 1
3147 assert actions[0].admin_user_id == super_user.id
3148 assert actions[0].tag == "hidden"
3149 assert actions[0].note == "bulk hide"
3152def test_SetUserContentVisibility_only_touches_target(db):
3153 super_user, super_token = generate_user(is_superuser=True)
3154 target, target_token = generate_user()
3155 other, other_token = generate_user()
3156 host, _ = generate_user()
3158 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3159 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3161 with requests_session(target_token) as api:
3162 target_hr_id = api.CreateHostRequest(
3163 requests_pb2.CreateHostRequestReq(
3164 host_user_id=host.id,
3165 from_date=today_plus_2,
3166 to_date=today_plus_3,
3167 text=valid_request_text(),
3168 )
3169 ).host_request_id
3171 with requests_session(other_token) as api:
3172 other_hr_id = api.CreateHostRequest(
3173 requests_pb2.CreateHostRequestReq(
3174 host_user_id=host.id,
3175 from_date=today_plus_2,
3176 to_date=today_plus_3,
3177 text=valid_request_text(),
3178 )
3179 ).host_request_id
3181 with real_moderation_session(super_token) as api:
3182 res = api.SetUserContentVisibility(
3183 moderation_pb2.SetUserContentVisibilityReq(
3184 user_id=target.id,
3185 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3186 )
3187 )
3188 assert res.updated_count == 1
3190 with session_scope() as session:
3191 target_state = _get_moderation_state(session, ModerationObjectType.host_request, target_hr_id)
3192 other_state = _get_moderation_state(session, ModerationObjectType.host_request, other_hr_id)
3193 assert target_state.visibility == ModerationVisibility.hidden
3194 assert other_state.visibility == ModerationVisibility.shadowed
3197def test_SetUserContentVisibility_user_not_found(db):
3198 super_user, super_token = generate_user(is_superuser=True)
3200 with real_moderation_session(super_token) as api:
3201 with pytest.raises(grpc.RpcError) as e:
3202 api.SetUserContentVisibility(
3203 moderation_pb2.SetUserContentVisibilityReq(
3204 user_id=999999,
3205 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3206 )
3207 )
3208 assert e.value.code() == grpc.StatusCode.NOT_FOUND
3211def test_SetUserContentVisibility_from_visibility_filter(db):
3212 super_user, super_token = generate_user(is_superuser=True)
3213 surfer, surfer_token = generate_user()
3214 host, _ = generate_user()
3216 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3217 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3218 with requests_session(surfer_token) as api:
3219 hr_id = api.CreateHostRequest(
3220 requests_pb2.CreateHostRequestReq(
3221 host_user_id=host.id,
3222 from_date=today_plus_2,
3223 to_date=today_plus_3,
3224 text=valid_request_text(),
3225 )
3226 ).host_request_id
3228 with real_moderation_session(super_token) as api:
3229 api.SetUserContentVisibility(
3230 moderation_pb2.SetUserContentVisibilityReq(
3231 user_id=surfer.id,
3232 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3233 )
3234 )
3236 with real_moderation_session(super_token) as api:
3237 res = api.SetUserContentVisibility(
3238 moderation_pb2.SetUserContentVisibilityReq(
3239 user_id=surfer.id,
3240 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3241 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_SHADOWED],
3242 )
3243 )
3244 assert res.updated_count == 0
3246 with session_scope() as session:
3247 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3248 assert state.visibility == ModerationVisibility.visible
3250 with real_moderation_session(super_token) as api:
3251 res = api.SetUserContentVisibility(
3252 moderation_pb2.SetUserContentVisibilityReq(
3253 user_id=surfer.id,
3254 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3255 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_VISIBLE],
3256 )
3257 )
3258 assert res.updated_count == 1
3260 with session_scope() as session:
3261 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3262 assert state.visibility == ModerationVisibility.shadowed
3265def test_SetUserContentVisibility_from_visibility_multi(db):
3266 super_user, super_token = generate_user(is_superuser=True)
3267 surfer, surfer_token = generate_user()
3268 host1, _ = generate_user()
3269 host2, _ = generate_user()
3271 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3272 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3273 with requests_session(surfer_token) as api:
3274 hr1_id = api.CreateHostRequest(
3275 requests_pb2.CreateHostRequestReq(
3276 host_user_id=host1.id,
3277 from_date=today_plus_2,
3278 to_date=today_plus_3,
3279 text=valid_request_text(),
3280 )
3281 ).host_request_id
3282 hr2_id = api.CreateHostRequest(
3283 requests_pb2.CreateHostRequestReq(
3284 host_user_id=host2.id,
3285 from_date=today_plus_2,
3286 to_date=today_plus_3,
3287 text=valid_request_text(),
3288 )
3289 ).host_request_id
3291 with session_scope() as session:
3292 state1_id = _get_moderation_state(session, ModerationObjectType.host_request, hr1_id).id
3294 with real_moderation_session(super_token) as api:
3295 api.ModerateContent(
3296 moderation_pb2.ModerateContentReq(
3297 moderation_state_id=state1_id,
3298 action=moderation_pb2.MODERATION_ACTION_APPROVE,
3299 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3300 )
3301 )
3303 with real_moderation_session(super_token) as api:
3304 res = api.SetUserContentVisibility(
3305 moderation_pb2.SetUserContentVisibilityReq(
3306 user_id=surfer.id,
3307 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3308 from_visibility=[
3309 moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3310 moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3311 ],
3312 )
3313 )
3314 assert res.updated_count == 2
3316 with session_scope() as session:
3317 state1 = _get_moderation_state(session, ModerationObjectType.host_request, hr1_id)
3318 state2 = _get_moderation_state(session, ModerationObjectType.host_request, hr2_id)
3319 assert state1.visibility == ModerationVisibility.hidden
3320 assert state2.visibility == ModerationVisibility.hidden
3323def test_SetUserContentVisibility_from_visibility_empty_is_any(db):
3324 super_user, super_token = generate_user(is_superuser=True)
3325 surfer, surfer_token = generate_user()
3326 host, _ = generate_user()
3328 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3329 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3330 with requests_session(surfer_token) as api:
3331 hr_id = api.CreateHostRequest(
3332 requests_pb2.CreateHostRequestReq(
3333 host_user_id=host.id,
3334 from_date=today_plus_2,
3335 to_date=today_plus_3,
3336 text=valid_request_text(),
3337 )
3338 ).host_request_id
3340 with real_moderation_session(super_token) as api:
3341 res = api.SetUserContentVisibility(
3342 moderation_pb2.SetUserContentVisibilityReq(
3343 user_id=surfer.id,
3344 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3345 from_visibility=[],
3346 )
3347 )
3348 assert res.updated_count == 1
3350 with session_scope() as session:
3351 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3352 assert state.visibility == ModerationVisibility.hidden
3355def test_SetUserContentVisibility_from_visibility_unspecified_rejected(db):
3356 super_user, super_token = generate_user(is_superuser=True)
3357 target, _ = generate_user()
3359 with real_moderation_session(super_token) as api:
3360 with pytest.raises(grpc.RpcError) as e:
3361 api.SetUserContentVisibility(
3362 moderation_pb2.SetUserContentVisibilityReq(
3363 user_id=target.id,
3364 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3365 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED],
3366 )
3367 )
3368 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
3371def test_ListModerationStates_empty(db):
3372 super_user, super_token = generate_user(is_superuser=True)
3374 with real_moderation_session(super_token) as api:
3375 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq())
3376 assert len(res.moderation_states) == 0
3377 assert res.next_page_token == ""
3380def test_ListModerationStates_returns_states_chronologically(db):
3381 super_user, super_token = generate_user(is_superuser=True)
3382 surfer, surfer_token = generate_user()
3383 host, _ = generate_user()
3385 state1_id = create_test_host_request_with_moderation(surfer_token, host.id)
3386 state2_id = create_test_host_request_with_moderation(surfer_token, host.id)
3387 state3_id = create_test_host_request_with_moderation(surfer_token, host.id)
3389 with real_moderation_session(super_token) as api:
3390 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq())
3391 assert [s.moderation_state_id for s in res.moderation_states] == [state1_id, state2_id, state3_id]
3393 res_newest = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(newest_first=True))
3394 assert [s.moderation_state_id for s in res_newest.moderation_states] == [state3_id, state2_id, state1_id]
3397def test_ListModerationStates_filter_by_author(db):
3398 super_user, super_token = generate_user(is_superuser=True)
3399 surfer1, surfer1_token = generate_user()
3400 surfer2, surfer2_token = generate_user()
3401 host, _ = generate_user()
3403 state1_id = create_test_host_request_with_moderation(surfer1_token, host.id)
3404 state2_id = create_test_host_request_with_moderation(surfer2_token, host.id)
3405 state3_id = create_test_host_request_with_moderation(surfer1_token, host.id)
3407 with real_moderation_session(super_token) as api:
3408 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer1.id))
3409 assert {s.moderation_state_id for s in res.moderation_states} == {state1_id, state3_id}
3411 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer2.id))
3412 assert [s.moderation_state_id for s in res.moderation_states] == [state2_id]
3415def test_ListModerationStates_pagination(db):
3416 super_user, super_token = generate_user(is_superuser=True)
3417 surfer, surfer_token = generate_user()
3418 host, _ = generate_user()
3420 state_ids = [create_test_host_request_with_moderation(surfer_token, host.id) for _ in range(3)]
3422 with real_moderation_session(super_token) as api:
3423 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(page_size=2))
3424 assert [s.moderation_state_id for s in res.moderation_states] == state_ids[:2]
3425 assert res.next_page_token != ""
3427 res2 = api.ListModerationStates(
3428 moderation_pb2.ListModerationStatesReq(page_size=2, page_token=res.next_page_token)
3429 )
3430 assert [s.moderation_state_id for s in res2.moderation_states] == [state_ids[2]]
3431 assert res2.next_page_token == ""