Coverage for app/backend/src/tests/test_moderation.py: 100%
1641 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-10 12:25 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-10 12:25 +0000
1"""
2Comprehensive tests for the Unified Moderation System (UMS)
3"""
5import json
6from datetime import datetime, timedelta
8import grpc
9import pytest
10from google.protobuf import empty_pb2
11from sqlalchemy.exc import IntegrityError
12from sqlalchemy.sql import select
14from couchers.config import config
15from couchers.constants import MODERATION_AUTO_APPROVE_FLAG_PRIORITY
16from couchers.db import session_scope
17from couchers.jobs.handlers import auto_approve_moderation_queue
18from couchers.jobs.worker import process_job
19from couchers.models import (
20 AdminAction,
21 EventOccurrence,
22 FriendRelationship,
23 GroupChat,
24 HostRequest,
25 ModerationAction,
26 ModerationLog,
27 ModerationObjectType,
28 ModerationQueueItem,
29 ModerationState,
30 ModerationTrigger,
31 ModerationVisibility,
32 User,
33 get_moderated_models,
34)
35from couchers.moderation.utils import create_moderation
36from couchers.proto import (
37 api_pb2,
38 conversations_pb2,
39 events_pb2,
40 messages_pb2,
41 moderation_pb2,
42 notifications_pb2,
43 requests_pb2,
44)
45from couchers.utils import Timestamp_from_datetime, datetime_to_iso8601_local, now, today
46from tests.fixtures.db import backdate_conversations, generate_user, make_friends
47from tests.fixtures.misc import EmailCollector, PushCollector, process_jobs
48from tests.fixtures.sessions import (
49 api_session,
50 conversations_session,
51 events_session,
52 notifications_session,
53 real_moderation_session,
54 requests_session,
55)
56from tests.test_communities import create_community
57from tests.test_requests import valid_request_text
60@pytest.fixture(autouse=True)
61def _(testconfig):
62 pass
65def create_test_host_request_with_moderation(surfer_token, host_user_id):
66 """Helper to create a host request and return its moderation state ID"""
67 today_plus_2 = (today() + timedelta(days=2)).isoformat()
68 today_plus_3 = (today() + timedelta(days=3)).isoformat()
70 with requests_session(surfer_token) as api:
71 hr_id = api.CreateHostRequest(
72 requests_pb2.CreateHostRequestReq(
73 host_user_id=host_user_id,
74 from_date=today_plus_2,
75 to_date=today_plus_3,
76 text=valid_request_text(),
77 )
78 ).host_request_id
80 backdate_conversations()
82 with session_scope() as session:
83 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
84 return hr.moderation_state_id
87# ============================================================================
88# Tests for moderation helper functions
89# ============================================================================
92def test_create_moderation(db):
93 """Test creating a moderation state with associated log entry"""
94 user, _ = generate_user()
96 with session_scope() as session:
97 # Create a moderation state
98 moderation_state = create_moderation(
99 session=session,
100 object_type=ModerationObjectType.host_request,
101 object_id=123,
102 creator_user_id=user.id,
103 )
105 assert moderation_state.object_type == ModerationObjectType.host_request
106 assert moderation_state.object_id == 123
107 assert moderation_state.visibility == ModerationVisibility.shadowed
109 # Check that log entry was created
110 log_entries = (
111 session.execute(select(ModerationLog).where(ModerationLog.moderation_state_id == moderation_state.id))
112 .scalars()
113 .all()
114 )
116 assert len(log_entries) == 1
117 assert log_entries[0].action == ModerationAction.create
118 assert log_entries[0].reason == "Object created."
119 assert log_entries[0].moderator_user_id == user.id
122def test_add_to_moderation_queue(db):
123 """Test adding content to moderation queue via API"""
124 super_user, super_token = generate_user(is_superuser=True)
125 user1, token1 = generate_user()
126 user2, _ = generate_user()
128 today_plus_2 = (today() + timedelta(days=2)).isoformat()
129 today_plus_3 = (today() + timedelta(days=3)).isoformat()
131 # Create a real host request (which automatically creates moderation state and adds to queue)
132 with requests_session(token1) as api:
133 host_request_id = api.CreateHostRequest(
134 requests_pb2.CreateHostRequestReq(
135 host_user_id=user2.id,
136 from_date=today_plus_2,
137 to_date=today_plus_3,
138 text=valid_request_text(),
139 )
140 ).host_request_id
142 # Get the moderation state ID
143 state_id = None
144 with session_scope() as session:
145 host_request = session.execute(
146 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
147 ).scalar_one()
148 state_id = host_request.moderation_state_id
150 # Add another item to moderation queue via API (the first one was created automatically)
151 with real_moderation_session(super_token) as api:
152 api.ModerateContent(
153 moderation_pb2.ModerateContentReq(
154 moderation_state_id=state_id,
155 action=moderation_pb2.MODERATION_ACTION_FLAG,
156 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
157 reason="Admin manually flagged for additional review",
158 )
159 )
161 with session_scope() as session:
162 flag = session.execute(
163 select(ModerationQueueItem)
164 .where(ModerationQueueItem.moderation_state_id == state_id)
165 .where(ModerationQueueItem.trigger == ModerationTrigger.user_flag)
166 ).scalar_one()
167 assert flag.reason == "Admin manually flagged for additional review"
168 assert flag.resolved_by_log_id is None
170 # The FLAG action is recorded in the log, pointing at the new queue item.
171 flag_log = session.execute(
172 select(ModerationLog)
173 .where(ModerationLog.moderation_state_id == state_id)
174 .where(ModerationLog.action == ModerationAction.flag)
175 ).scalar_one()
176 assert flag_log.queue_item_id == flag.id
179def test_flag_with_data(db):
180 """A flag can carry a JSON payload"""
181 super_user, super_token = generate_user(is_superuser=True)
182 user1, token1 = generate_user()
183 user2, _ = generate_user()
185 state_id = create_test_host_request_with_moderation(token1, user2.id)
187 with real_moderation_session(super_token) as api:
188 api.ModerateContent(
189 moderation_pb2.ModerateContentReq(
190 moderation_state_id=state_id,
191 action=moderation_pb2.MODERATION_ACTION_FLAG,
192 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG,
193 reason="Automod",
194 data=json.dumps({"classifier": "spam", "score": 0.97, "matches": ["buy now"]}),
195 )
196 )
198 res = api.GetModerationQueue(
199 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG])
200 )
201 assert len(res.queue_items) == 1
202 assert json.loads(res.queue_items[0].data) == {
203 "classifier": "spam",
204 "score": 0.97,
205 "matches": ["buy now"],
206 }
208 with session_scope() as session:
209 flag = session.execute(
210 select(ModerationQueueItem)
211 .where(ModerationQueueItem.moderation_state_id == state_id)
212 .where(ModerationQueueItem.trigger == ModerationTrigger.machine_flag)
213 ).scalar_one()
214 assert flag.data == {"classifier": "spam", "score": 0.97, "matches": ["buy now"]}
217def test_flag_without_data(db):
218 """A flag with no data serializes as an empty string"""
219 super_user, super_token = generate_user(is_superuser=True)
220 user1, token1 = generate_user()
221 user2, _ = generate_user()
223 state_id = create_test_host_request_with_moderation(token1, user2.id)
225 with real_moderation_session(super_token) as api:
226 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
227 assert len(res.queue_items) == 1
228 assert res.queue_items[0].data == ""
230 with session_scope() as session:
231 item = session.execute(
232 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
233 ).scalar_one()
234 assert item.data is None
237def test_flag_with_invalid_data(db):
238 """Flag data must be valid JSON"""
239 super_user, super_token = generate_user(is_superuser=True)
240 user1, token1 = generate_user()
241 user2, _ = generate_user()
243 state_id = create_test_host_request_with_moderation(token1, user2.id)
245 with real_moderation_session(super_token) as api:
246 with pytest.raises(grpc.RpcError) as e:
247 api.ModerateContent(
248 moderation_pb2.ModerateContentReq(
249 moderation_state_id=state_id,
250 action=moderation_pb2.MODERATION_ACTION_FLAG,
251 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG,
252 reason="Automod",
253 data="not json",
254 )
255 )
256 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
257 assert e.value.details() == "The moderation data must be valid JSON."
260def test_data_rejected_on_non_flag_action(db):
261 """Data has nowhere to go on actions that don't create a queue item"""
262 super_user, super_token = generate_user(is_superuser=True)
263 user1, token1 = generate_user()
264 user2, _ = generate_user()
266 state_id = create_test_host_request_with_moderation(token1, user2.id)
268 with real_moderation_session(super_token) as api:
269 with pytest.raises(grpc.RpcError) as e:
270 api.ModerateContent(
271 moderation_pb2.ModerateContentReq(
272 moderation_state_id=state_id,
273 action=moderation_pb2.MODERATION_ACTION_APPROVE,
274 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
275 reason="Looks fine",
276 data=json.dumps({"score": 0.1}),
277 )
278 )
279 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
280 assert e.value.details() == "Moderation data can only be set when flagging."
283def test_create_moderation_with_data(db):
284 """create_moderation can attach data to the initial review item"""
285 user, _ = generate_user()
287 with session_scope() as session:
288 moderation_state = create_moderation(
289 session=session,
290 object_type=ModerationObjectType.host_request,
291 object_id=123,
292 creator_user_id=user.id,
293 data={"classifier": "spam", "score": 0.42},
294 )
296 item = session.execute(
297 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == moderation_state.id)
298 ).scalar_one()
299 assert item.trigger == ModerationTrigger.initial_review
300 assert item.data == {"classifier": "spam", "score": 0.42}
303def test_moderate_content(db):
304 """Test moderating content via API"""
305 super_user, super_token = generate_user(is_superuser=True)
306 user, token = generate_user()
307 host, _ = generate_user()
309 today_plus_2 = (today() + timedelta(days=2)).isoformat()
310 today_plus_3 = (today() + timedelta(days=3)).isoformat()
312 # Create a real host request
313 state_id = None
314 with requests_session(token) as api:
315 hr_id = api.CreateHostRequest(
316 requests_pb2.CreateHostRequestReq(
317 host_user_id=host.id,
318 from_date=today_plus_2,
319 to_date=today_plus_3,
320 text=valid_request_text(),
321 )
322 ).host_request_id
324 with session_scope() as session:
325 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
326 state_id = hr.moderation_state_id
328 # Moderate the content via API
329 with real_moderation_session(super_token) as api:
330 res = api.ModerateContent(
331 moderation_pb2.ModerateContentReq(
332 moderation_state_id=state_id,
333 action=moderation_pb2.MODERATION_ACTION_APPROVE,
334 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
335 reason="Content looks good",
336 )
337 )
339 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
341 # Check that state was updated in database
342 with session_scope() as session:
343 updated_state = session.get_one(ModerationState, state_id)
344 assert updated_state.visibility == ModerationVisibility.visible
346 # Check that log entry was created
347 log_entries = (
348 session.execute(
349 select(ModerationLog)
350 .where(ModerationLog.moderation_state_id == state_id)
351 .order_by(ModerationLog.time.desc(), ModerationLog.id.desc())
352 )
353 .scalars()
354 .all()
355 )
357 assert len(log_entries) == 2 # CREATE + APPROVE
358 assert log_entries[0].action == ModerationAction.approve
359 assert log_entries[0].moderator_user_id == super_user.id
360 assert log_entries[0].reason == "Content looks good"
363def test_resolve_queue_item(db):
364 """Test resolving a moderation queue item via ModerateContent API"""
365 user1, token1 = generate_user()
366 user2, _ = generate_user()
367 moderator, moderator_token = generate_user(is_superuser=True)
369 today_plus_2 = (today() + timedelta(days=2)).isoformat()
370 today_plus_3 = (today() + timedelta(days=3)).isoformat()
372 # Create a host request using the API (which automatically creates moderation state)
373 with requests_session(token1) as api:
374 host_request_id = api.CreateHostRequest(
375 requests_pb2.CreateHostRequestReq(
376 host_user_id=user2.id,
377 from_date=today_plus_2,
378 to_date=today_plus_3,
379 text=valid_request_text(),
380 )
381 ).host_request_id
383 state_id = None
384 with session_scope() as session:
385 # Get the host request and its moderation state
386 host_request = session.execute(
387 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
388 ).scalar_one()
389 state_id = host_request.moderation_state_id
391 # The moderation state should already exist and be in the queue
392 queue_item = session.execute(
393 select(ModerationQueueItem)
394 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
395 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
396 ).scalar_one()
398 assert queue_item.resolved_by_log_id is None
400 # Approve content with clear_flags, which resolves the open queue item(s)
401 with real_moderation_session(moderator_token) as api:
402 api.ModerateContent(
403 moderation_pb2.ModerateContentReq(
404 moderation_state_id=state_id,
405 action=moderation_pb2.MODERATION_ACTION_APPROVE,
406 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
407 reason="Approved after review",
408 clear_flags=True,
409 )
410 )
412 # Check that queue item was resolved
413 with session_scope() as session:
414 queue_item = session.execute(
415 select(ModerationQueueItem)
416 .where(ModerationQueueItem.moderation_state_id == state_id)
417 .where(ModerationQueueItem.resolved_by_log_id.is_not(None))
418 ).scalar_one()
419 assert queue_item.resolved_by_log_id is not None
422def test_approve_content_via_api(db):
423 """Test approving content via ModerateContent API"""
424 user1, token1 = generate_user()
425 user2, _ = generate_user()
426 moderator, moderator_token = generate_user(is_superuser=True)
428 today_plus_2 = (today() + timedelta(days=2)).isoformat()
429 today_plus_3 = (today() + timedelta(days=3)).isoformat()
431 # Create a host request using the API (which automatically creates moderation state)
432 with requests_session(token1) as api:
433 host_request_id = api.CreateHostRequest(
434 requests_pb2.CreateHostRequestReq(
435 host_user_id=user2.id,
436 from_date=today_plus_2,
437 to_date=today_plus_3,
438 text=valid_request_text(),
439 )
440 ).host_request_id
442 state_id = None
443 with session_scope() as session:
444 # Get the host request and its moderation state
445 host_request = session.execute(
446 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
447 ).scalar_one()
448 state_id = host_request.moderation_state_id
450 # Approve via API
451 with real_moderation_session(moderator_token) as api:
452 api.ModerateContent(
453 moderation_pb2.ModerateContentReq(
454 moderation_state_id=state_id,
455 action=moderation_pb2.MODERATION_ACTION_APPROVE,
456 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
457 reason="Quick approval",
458 )
459 )
461 # Check that state was updated to VISIBLE
462 with session_scope() as session:
463 updated_state = session.get_one(ModerationState, state_id)
464 assert updated_state.visibility == ModerationVisibility.visible
466 # Check log entry
467 log_entry = session.execute(
468 select(ModerationLog)
469 .where(ModerationLog.moderation_state_id == state_id)
470 .where(ModerationLog.action == ModerationAction.approve)
471 ).scalar_one()
473 assert log_entry.moderator_user_id == moderator.id
474 assert log_entry.reason == "Quick approval"
477# ============================================================================
478# Tests for host request moderation integration
479# ============================================================================
482def test_create_host_request_creates_moderation_state(db):
483 """Test that creating a host request automatically creates a moderation state"""
484 user1, token1 = generate_user()
485 user2, token2 = generate_user()
487 today_plus_2 = (today() + timedelta(days=2)).isoformat()
488 today_plus_3 = (today() + timedelta(days=3)).isoformat()
490 with requests_session(token1) as api:
491 host_request_id = api.CreateHostRequest(
492 requests_pb2.CreateHostRequestReq(
493 host_user_id=user2.id,
494 from_date=today_plus_2,
495 to_date=today_plus_3,
496 text=valid_request_text(),
497 )
498 ).host_request_id
500 with session_scope() as session:
501 # Check that host request has a moderation state
502 host_request = session.execute(
503 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
504 ).scalar_one()
506 # Check moderation state properties
507 moderation_state = session.execute(
508 select(ModerationState).where(ModerationState.id == host_request.moderation_state_id)
509 ).scalar_one()
511 assert moderation_state.object_type == ModerationObjectType.host_request
512 assert moderation_state.object_id == host_request_id
513 assert moderation_state.visibility == ModerationVisibility.shadowed
515 # Check that it was added to moderation queue
516 queue_items = (
517 session.execute(
518 select(ModerationQueueItem)
519 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
520 .where(ModerationQueueItem.resolved_by_log_id == None)
521 )
522 .scalars()
523 .all()
524 )
526 assert len(queue_items) == 1
527 assert queue_items[0].trigger == ModerationTrigger.initial_review
528 # item_author_user_id is no longer stored in the model, it's dynamically retrieved
531def test_host_request_no_notification_before_approval(db, push_collector: PushCollector):
532 """Test that host requests don't send notifications until approved"""
533 user1, token1 = generate_user()
534 user2, token2 = generate_user()
536 today_plus_2 = (today() + timedelta(days=2)).isoformat()
537 today_plus_3 = (today() + timedelta(days=3)).isoformat()
539 with requests_session(token1) as api:
540 host_request_id = api.CreateHostRequest(
541 requests_pb2.CreateHostRequestReq(
542 host_user_id=user2.id,
543 from_date=today_plus_2,
544 to_date=today_plus_3,
545 text=valid_request_text(),
546 )
547 ).host_request_id
549 # Process all jobs (including the notification job)
550 process_jobs()
552 # No push notification should be sent yet (host requests are shadowed initially)
553 assert push_collector.count_for_user(user2.id) == 0
556def test_shadowed_notification_not_in_list_notifications(db):
557 """Test that notifications for shadowed content don't appear in ListNotifications API"""
558 user1, token1 = generate_user()
559 user2, token2 = generate_user()
561 today_plus_2 = (today() + timedelta(days=2)).isoformat()
562 today_plus_3 = (today() + timedelta(days=3)).isoformat()
564 # Create a host request (which creates a shadowed notification for the host)
565 with requests_session(token1) as api:
566 host_request_id = api.CreateHostRequest(
567 requests_pb2.CreateHostRequestReq(
568 host_user_id=user2.id,
569 from_date=today_plus_2,
570 to_date=today_plus_3,
571 text=valid_request_text(),
572 )
573 ).host_request_id
575 # Host (recipient) should NOT see the notification in ListNotifications - it's for shadowed content
576 with notifications_session(token2) as api:
577 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
578 # Should be empty - the host request is still shadowed
579 assert len(res.notifications) == 0
582def test_notification_visible_after_approval(db):
583 """Test that notifications appear in ListNotifications after content is approved"""
584 user1, token1 = generate_user()
585 user2, token2 = generate_user()
586 mod, mod_token = generate_user(is_superuser=True)
588 today_plus_2 = (today() + timedelta(days=2)).isoformat()
589 today_plus_3 = (today() + timedelta(days=3)).isoformat()
591 # Create a host request (which creates a shadowed notification for the host)
592 with requests_session(token1) as api:
593 host_request_id = api.CreateHostRequest(
594 requests_pb2.CreateHostRequestReq(
595 host_user_id=user2.id,
596 from_date=today_plus_2,
597 to_date=today_plus_3,
598 text=valid_request_text(),
599 )
600 ).host_request_id
602 # Host (recipient) should NOT see the notification initially
603 with notifications_session(token2) as api:
604 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
605 assert len(res.notifications) == 0
607 # Get the moderation state ID and approve
608 with session_scope() as session:
609 host_request = session.execute(
610 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
611 ).scalar_one()
612 state_id = host_request.moderation_state_id
614 with real_moderation_session(mod_token) as api:
615 api.ModerateContent(
616 moderation_pb2.ModerateContentReq(
617 moderation_state_id=state_id,
618 action=moderation_pb2.MODERATION_ACTION_APPROVE,
619 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
620 reason="Looks good",
621 )
622 )
624 # Now host SHOULD see the notification
625 with notifications_session(token2) as api:
626 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
627 assert len(res.notifications) == 1
628 assert res.notifications[0].topic == "host_request"
629 assert res.notifications[0].action == "create"
632def test_shadowed_host_request_visible_to_author_only(db):
633 """Test that SHADOWED host requests are visible only to the author (surfer), not the recipient (host)"""
634 user1, token1 = generate_user()
635 user2, token2 = generate_user()
637 today_plus_2 = (today() + timedelta(days=2)).isoformat()
638 today_plus_3 = (today() + timedelta(days=3)).isoformat()
640 with requests_session(token1) as api:
641 host_request_id = api.CreateHostRequest(
642 requests_pb2.CreateHostRequestReq(
643 host_user_id=user2.id,
644 from_date=today_plus_2,
645 to_date=today_plus_3,
646 text=valid_request_text(),
647 )
648 ).host_request_id
650 # Surfer (author) can see it with GetHostRequest
651 with requests_session(token1) as api:
652 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
653 assert res.host_request_id == host_request_id
654 assert res.latest_message.text.text == valid_request_text()
656 # Host (recipient) CANNOT see it with GetHostRequest - it's shadowed
657 with requests_session(token2) as api:
658 with pytest.raises(grpc.RpcError) as e:
659 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
660 assert e.value.code() == grpc.StatusCode.NOT_FOUND
663def test_unlisted_host_request_not_in_lists(db):
664 """Test that SHADOWED host requests are visible to author but not to recipient"""
665 user1, token1 = generate_user()
666 user2, token2 = generate_user()
668 today_plus_2 = (today() + timedelta(days=2)).isoformat()
669 today_plus_3 = (today() + timedelta(days=3)).isoformat()
671 with requests_session(token1) as api:
672 host_request_id = api.CreateHostRequest(
673 requests_pb2.CreateHostRequestReq(
674 host_user_id=user2.id,
675 from_date=today_plus_2,
676 to_date=today_plus_3,
677 text=valid_request_text(),
678 )
679 ).host_request_id
681 # Surfer (author) should see it in their sent list even though it's SHADOWED
682 with requests_session(token1) as api:
683 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
684 assert len(res.host_requests) == 1
686 # Host should NOT see it in their received list (still SHADOWED from them)
687 with requests_session(token2) as api:
688 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
689 assert len(res.host_requests) == 0
692def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector):
693 """Test that approved host requests appear in lists and send notifications"""
694 user1, token1 = generate_user()
695 user2, token2 = generate_user()
696 mod, mod_token = generate_user(is_superuser=True)
698 today_plus_2 = (today() + timedelta(days=2)).isoformat()
699 today_plus_3 = (today() + timedelta(days=3)).isoformat()
701 with requests_session(token1) as api:
702 host_request_id = api.CreateHostRequest(
703 requests_pb2.CreateHostRequestReq(
704 host_user_id=user2.id,
705 from_date=today_plus_2,
706 to_date=today_plus_3,
707 text=valid_request_text(),
708 )
709 ).host_request_id
711 # Process the initial notification job - should be deferred (no notification sent)
712 process_jobs()
713 assert push_collector.count_for_user(user2.id) == 0
715 # Get the moderation state ID
716 state_id = None
717 with session_scope() as session:
718 host_request = session.execute(
719 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
720 ).scalar_one()
721 state_id = host_request.moderation_state_id
723 # Approve the host request via API
724 with real_moderation_session(mod_token) as api:
725 api.ModerateContent(
726 moderation_pb2.ModerateContentReq(
727 moderation_state_id=state_id,
728 action=moderation_pb2.MODERATION_ACTION_APPROVE,
729 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
730 reason="Looks good",
731 )
732 )
734 # Process the re-queued notification job - should now send notification
735 process_jobs()
737 # Now surfer SHOULD see it in their sent list
738 with requests_session(token1) as api:
739 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
740 assert len(res.host_requests) == 1
741 assert res.host_requests[0].host_request_id == host_request_id
743 # Host SHOULD see it in their received list
744 with requests_session(token2) as api:
745 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
746 assert len(res.host_requests) == 1
747 assert res.host_requests[0].host_request_id == host_request_id
749 # After approval, the host should have received a push notification
750 assert push_collector.pop_for_user(user2.id, last=True).topic_action == "host_request:create"
753def test_hidden_host_request_invisible_to_all(db):
754 """Test that HIDDEN host requests are invisible to everyone except moderators"""
755 user1, token1 = generate_user()
756 user2, token2 = generate_user()
757 user3, token3 = generate_user() # Third party
758 moderator, moderator_token = generate_user(is_superuser=True)
760 today_plus_2 = (today() + timedelta(days=2)).isoformat()
761 today_plus_3 = (today() + timedelta(days=3)).isoformat()
763 with requests_session(token1) as api:
764 host_request_id = api.CreateHostRequest(
765 requests_pb2.CreateHostRequestReq(
766 host_user_id=user2.id,
767 from_date=today_plus_2,
768 to_date=today_plus_3,
769 text=valid_request_text(),
770 )
771 ).host_request_id
773 # Get the moderation state ID
774 state_id = None
775 with session_scope() as session:
776 host_request = session.execute(
777 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
778 ).scalar_one()
779 state_id = host_request.moderation_state_id
781 # Hide the host request via API (e.g., spam/abuse)
782 with real_moderation_session(moderator_token) as api:
783 api.ModerateContent(
784 moderation_pb2.ModerateContentReq(
785 moderation_state_id=state_id,
786 action=moderation_pb2.MODERATION_ACTION_HIDE,
787 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
788 reason="Spam content",
789 )
790 )
792 # Surfer can't see it with GetHostRequest
793 with requests_session(token1) as api:
794 with pytest.raises(grpc.RpcError) as e:
795 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
796 assert e.value.code() == grpc.StatusCode.NOT_FOUND
798 # Host can't see it with GetHostRequest
799 with requests_session(token2) as api:
800 with pytest.raises(grpc.RpcError) as e:
801 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
802 assert e.value.code() == grpc.StatusCode.NOT_FOUND
804 # Third party definitely can't see it
805 with requests_session(token3) as api:
806 with pytest.raises(grpc.RpcError) as e:
807 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
808 assert e.value.code() == grpc.StatusCode.NOT_FOUND
810 # Not in any lists
811 with requests_session(token1) as api:
812 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
813 assert len(res.host_requests) == 0
815 with requests_session(token2) as api:
816 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
817 assert len(res.host_requests) == 0
820def test_multiple_host_requests_listing_visibility(db):
821 """Test that ListHostRequests correctly filters based on moderation state"""
822 user1, token1 = generate_user()
823 user2, token2 = generate_user()
824 moderator, moderator_token = generate_user(is_superuser=True)
826 today_plus_2 = (today() + timedelta(days=2)).isoformat()
827 today_plus_3 = (today() + timedelta(days=3)).isoformat()
829 # Create 3 host requests
830 host_request_ids = []
831 state_ids = []
832 with requests_session(token1) as api:
833 for i in range(3):
834 hr_id = api.CreateHostRequest(
835 requests_pb2.CreateHostRequestReq(
836 host_user_id=user2.id,
837 from_date=today_plus_2,
838 to_date=today_plus_3,
839 text=valid_request_text(f"Test request {i + 1}"),
840 )
841 ).host_request_id
842 host_request_ids.append(hr_id)
843 backdate_conversations()
845 # Get state IDs
846 with session_scope() as session:
847 for hr_id in host_request_ids:
848 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
849 state_ids.append(host_request.moderation_state_id)
851 # Approve the first one via API
852 with real_moderation_session(moderator_token) as api:
853 api.ModerateContent(
854 moderation_pb2.ModerateContentReq(
855 moderation_state_id=state_ids[0],
856 action=moderation_pb2.MODERATION_ACTION_APPROVE,
857 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
858 reason="Approved",
859 )
860 )
862 # Hide the third one via API
863 with real_moderation_session(moderator_token) as api:
864 api.ModerateContent(
865 moderation_pb2.ModerateContentReq(
866 moderation_state_id=state_ids[2],
867 action=moderation_pb2.MODERATION_ACTION_HIDE,
868 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
869 reason="Spam",
870 )
871 )
873 # Surfer should see the approved one and the shadowed one (author can see their SHADOWED content)
874 with requests_session(token1) as api:
875 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
876 assert len(res.host_requests) == 2
877 visible_ids = {hr.host_request_id for hr in res.host_requests}
878 assert visible_ids == {host_request_ids[0], host_request_ids[1]}
880 # Host should see only the approved one in received list
881 with requests_session(token2) as api:
882 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
883 assert len(res.host_requests) == 1
884 assert res.host_requests[0].host_request_id == host_request_ids[0]
887def test_moderation_log_tracking(db):
888 """Test that moderation actions are properly logged via API"""
889 user, user_token = generate_user()
890 host, _ = generate_user()
891 moderator1, moderator1_token = generate_user(is_superuser=True)
892 moderator2, moderator2_token = generate_user(is_superuser=True)
894 # Create a real host request
895 state_id = create_test_host_request_with_moderation(user_token, host.id)
897 # Perform several moderation actions via API
898 with real_moderation_session(moderator1_token) as api:
899 api.ModerateContent(
900 moderation_pb2.ModerateContentReq(
901 moderation_state_id=state_id,
902 action=moderation_pb2.MODERATION_ACTION_APPROVE,
903 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
904 reason="Looks good initially",
905 )
906 )
908 with real_moderation_session(moderator2_token) as api:
909 api.ModerateContent(
910 moderation_pb2.ModerateContentReq(
911 moderation_state_id=state_id,
912 action=moderation_pb2.MODERATION_ACTION_FLAG,
913 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
914 reason="Wait, this needs another look",
915 )
916 )
917 # Shadow it back
918 api.ModerateContent(
919 moderation_pb2.ModerateContentReq(
920 moderation_state_id=state_id,
921 action=moderation_pb2.MODERATION_ACTION_HIDE,
922 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
923 reason="Wait, this needs another look",
924 )
925 )
927 with real_moderation_session(moderator1_token) as api:
928 api.ModerateContent(
929 moderation_pb2.ModerateContentReq(
930 moderation_state_id=state_id,
931 action=moderation_pb2.MODERATION_ACTION_HIDE,
932 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
933 reason="Actually it's spam",
934 )
935 )
937 # Check all log entries
938 with session_scope() as session:
939 log_entries = (
940 session.execute(
941 select(ModerationLog)
942 .where(ModerationLog.moderation_state_id == state_id)
943 .order_by(ModerationLog.time.asc())
944 )
945 .scalars()
946 .all()
947 )
949 # CREATE + APPROVE + HIDE + HIDE (shadowing back counts as HIDE action)
950 assert len(log_entries) >= 3
952 assert log_entries[0].action == ModerationAction.create
953 assert log_entries[0].moderator_user_id == user.id
954 assert log_entries[0].reason == "Object created."
956 assert log_entries[1].action == ModerationAction.approve
957 assert log_entries[1].moderator_user_id == moderator1.id
958 assert log_entries[1].reason == "Looks good initially"
960 # The last action should be hiding
961 assert log_entries[-1].action == ModerationAction.hide
962 assert log_entries[-1].moderator_user_id == moderator1.id
963 assert log_entries[-1].reason == "Actually it's spam"
966def test_moderation_queue_workflow(db):
967 """Test the full moderation queue workflow via API"""
968 user1, token1 = generate_user()
969 user2, _ = generate_user()
970 moderator, moderator_token = generate_user(is_superuser=True)
972 today_plus_2 = (today() + timedelta(days=2)).isoformat()
973 today_plus_3 = (today() + timedelta(days=3)).isoformat()
975 # Create a host request using the API (which automatically creates moderation state and adds to queue)
976 with requests_session(token1) as api:
977 host_request_id = api.CreateHostRequest(
978 requests_pb2.CreateHostRequestReq(
979 host_user_id=user2.id,
980 from_date=today_plus_2,
981 to_date=today_plus_3,
982 text=valid_request_text(),
983 )
984 ).host_request_id
986 state_id = None
987 queue_item_id = None
988 with session_scope() as session:
989 # Get the host request and its moderation state
990 host_request = session.execute(
991 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
992 ).scalar_one()
993 state_id = host_request.moderation_state_id
995 # The queue item should already exist (created automatically)
996 queue_item = session.execute(
997 select(ModerationQueueItem)
998 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
999 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1000 ).scalar_one()
1001 queue_item_id = queue_item.id
1003 # Verify it's in the queue
1004 unresolved_items = (
1005 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
1006 .scalars()
1007 .all()
1008 )
1010 assert len(unresolved_items) >= 1
1011 assert queue_item.id in [item.id for item in unresolved_items]
1013 # Moderator reviews and approves via API, clearing the open queue item
1014 with real_moderation_session(moderator_token) as api:
1015 api.ModerateContent(
1016 moderation_pb2.ModerateContentReq(
1017 moderation_state_id=state_id,
1018 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1019 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1020 reason="Content approved",
1021 clear_flags=True,
1022 )
1023 )
1025 # Verify queue item was resolved
1026 with session_scope() as session:
1027 # Verify it's no longer in unresolved queue
1028 unresolved_items = (
1029 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
1030 .scalars()
1031 .all()
1032 )
1034 assert queue_item_id not in [item.id for item in unresolved_items]
1036 # Verify the queue item was linked to a log entry
1037 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
1038 assert queue_item.resolved_by_log_id is not None
1041# ============================================================================
1042# Moderation API Tests (testing the gRPC servicer)
1043# ============================================================================
1046def test_GetModerationQueue_empty(db):
1047 """Test getting an empty moderation queue"""
1048 super_user, super_token = generate_user(is_superuser=True)
1050 with real_moderation_session(super_token) as api:
1051 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1052 assert len(res.queue_items) == 0
1053 assert res.next_page_token == ""
1056def test_GetModerationQueue_with_items(db):
1057 """Test getting moderation queue with items via API"""
1058 super_user, super_token = generate_user(is_superuser=True)
1059 normal_user, user_token = generate_user()
1060 host, _ = generate_user()
1062 # Create some host requests (which automatically adds them to moderation queue)
1063 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1064 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1066 with real_moderation_session(super_token) as api:
1067 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1068 assert len(res.queue_items) == 2
1069 assert res.queue_items[0].is_resolved == False
1070 assert res.queue_items[1].is_resolved == False
1073def test_GetModerationQueue_filter_by_trigger(db):
1074 """Test filtering moderation queue by trigger type via API"""
1075 super_user, super_token = generate_user(is_superuser=True)
1076 normal_user, user_token = generate_user()
1077 host, _ = generate_user()
1079 # Create host requests (which automatically adds them to moderation queue with INITIAL_REVIEW)
1080 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1081 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1083 # Add USER_FLAG trigger to second item via API
1084 with real_moderation_session(super_token) as api:
1085 api.ModerateContent(
1086 moderation_pb2.ModerateContentReq(
1087 moderation_state_id=state2_id,
1088 action=moderation_pb2.MODERATION_ACTION_FLAG,
1089 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
1090 reason="Reported by user",
1091 )
1092 )
1094 # Filter by INITIAL_REVIEW (should get first item and maybe both depending on how queue works)
1095 with real_moderation_session(super_token) as api:
1096 res = api.GetModerationQueue(
1097 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW])
1098 )
1099 assert len(res.queue_items) == 2 # Both have INITIAL_REVIEW triggers
1100 assert all(item.trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW for item in res.queue_items)
1102 # Filter by USER_FLAG (should get second item only)
1103 with real_moderation_session(super_token) as api:
1104 res = api.GetModerationQueue(
1105 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_USER_FLAG])
1106 )
1107 assert len(res.queue_items) == 1
1108 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG
1111def test_GetModerationQueue_filter_created_before(db):
1112 """Test filtering moderation queue by created_before timestamp"""
1113 super_user, super_token = generate_user(is_superuser=True)
1114 normal_user, user_token = generate_user()
1115 host, _ = generate_user()
1117 # Create host requests
1118 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1119 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1121 # Backdate the first queue item
1122 with session_scope() as session:
1123 queue_item1 = session.execute(
1124 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1125 ).scalar_one()
1126 # Set it to 2 hours ago
1127 queue_item1.time_created = now() - timedelta(hours=2)
1129 # The second item remains at current time
1131 # Filter to items created before 1 hour ago (should only get the first item)
1132 cutoff_time = now() - timedelta(hours=1)
1133 with real_moderation_session(super_token) as api:
1134 res = api.GetModerationQueue(
1135 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(cutoff_time))
1136 )
1137 assert len(res.queue_items) == 1
1138 assert res.queue_items[0].moderation_state_id == state1_id
1140 # Filter to items created before now (should get both)
1141 with real_moderation_session(super_token) as api:
1142 res = api.GetModerationQueue(
1143 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(now() + timedelta(seconds=10)))
1144 )
1145 assert len(res.queue_items) == 2
1147 # Filter to items created before 3 hours ago (should get none)
1148 old_cutoff = now() - timedelta(hours=3)
1149 with real_moderation_session(super_token) as api:
1150 res = api.GetModerationQueue(
1151 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(old_cutoff))
1152 )
1153 assert len(res.queue_items) == 0
1156def test_GetModerationQueue_filter_created_after(db):
1157 """Test filtering moderation queue by created_after timestamp"""
1158 super_user, super_token = generate_user(is_superuser=True)
1159 normal_user, user_token = generate_user()
1160 host, _ = generate_user()
1162 # Create host requests
1163 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1164 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1166 # Backdate the first queue item to 2 hours ago
1167 with session_scope() as session:
1168 queue_item1 = session.execute(
1169 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1170 ).scalar_one()
1171 queue_item1.time_created = now() - timedelta(hours=2)
1173 # The second item remains at current time
1175 # Filter to items created after 1 hour ago (should only get the second item)
1176 cutoff_time = now() - timedelta(hours=1)
1177 with real_moderation_session(super_token) as api:
1178 res = api.GetModerationQueue(
1179 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(cutoff_time))
1180 )
1181 assert len(res.queue_items) == 1
1182 assert res.queue_items[0].moderation_state_id == state2_id
1184 # Filter to items created after 3 hours ago (should get both)
1185 old_cutoff = now() - timedelta(hours=3)
1186 with real_moderation_session(super_token) as api:
1187 res = api.GetModerationQueue(
1188 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(old_cutoff))
1189 )
1190 assert len(res.queue_items) == 2
1192 # Filter to items created after now (should get none)
1193 with real_moderation_session(super_token) as api:
1194 res = api.GetModerationQueue(
1195 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(now() + timedelta(seconds=10)))
1196 )
1197 assert len(res.queue_items) == 0
1200def test_GetModerationQueue_filter_created_before_and_after(db):
1201 """Test filtering moderation queue by both created_before and created_after timestamps"""
1202 super_user, super_token = generate_user(is_superuser=True)
1203 normal_user, user_token = generate_user()
1204 host, _ = generate_user()
1206 # Create 3 host requests
1207 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1208 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1209 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1211 # Set different times: state1 = 3 hours ago, state2 = 1.5 hours ago, state3 = now
1212 with session_scope() as session:
1213 queue_item1 = session.execute(
1214 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1215 ).scalar_one()
1216 queue_item1.time_created = now() - timedelta(hours=3)
1218 queue_item2 = session.execute(
1219 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1220 ).scalar_one()
1221 queue_item2.time_created = now() - timedelta(hours=1, minutes=30)
1223 # Filter to items between 2 hours ago and 1 hour ago (should only get state2)
1224 after_cutoff = now() - timedelta(hours=2)
1225 before_cutoff = now() - timedelta(hours=1)
1226 with real_moderation_session(super_token) as api:
1227 res = api.GetModerationQueue(
1228 moderation_pb2.GetModerationQueueReq(
1229 created_after=Timestamp_from_datetime(after_cutoff),
1230 created_before=Timestamp_from_datetime(before_cutoff),
1231 )
1232 )
1233 assert len(res.queue_items) == 1
1234 assert res.queue_items[0].moderation_state_id == state2_id
1236 # Filter to items between 4 hours ago and 2.5 hours ago (should only get state1)
1237 after_cutoff = now() - timedelta(hours=4)
1238 before_cutoff = now() - timedelta(hours=2, minutes=30)
1239 with real_moderation_session(super_token) as api:
1240 res = api.GetModerationQueue(
1241 moderation_pb2.GetModerationQueueReq(
1242 created_after=Timestamp_from_datetime(after_cutoff),
1243 created_before=Timestamp_from_datetime(before_cutoff),
1244 )
1245 )
1246 assert len(res.queue_items) == 1
1247 assert res.queue_items[0].moderation_state_id == state1_id
1250def test_GetModerationQueue_filter_unresolved(db):
1251 """Test filtering moderation queue for unresolved items only via API"""
1252 super_user, super_token = generate_user(is_superuser=True)
1253 normal_user, user_token = generate_user()
1254 host, _ = generate_user()
1256 # Create 2 host requests
1257 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1258 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1260 # Resolve the first one via API (approve with clear_flags resolves the queue item)
1261 with real_moderation_session(super_token) as api:
1262 api.ModerateContent(
1263 moderation_pb2.ModerateContentReq(
1264 moderation_state_id=state1_id,
1265 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1266 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1267 reason="Approved",
1268 clear_flags=True,
1269 )
1270 )
1272 # Get all items
1273 with real_moderation_session(super_token) as api:
1274 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1275 assert len(res.queue_items) == 2
1277 # Get only unresolved items
1278 with real_moderation_session(super_token) as api:
1279 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1280 assert len(res.queue_items) == 1
1281 assert res.queue_items[0].is_resolved == False
1284def test_GetModerationQueue_filter_by_author(db):
1285 """Test filtering moderation queue by item_author_user_id"""
1286 super_user, super_token = generate_user(is_superuser=True)
1287 user1, token1 = generate_user()
1288 user2, token2 = generate_user()
1289 host_user, _ = generate_user()
1291 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1292 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1294 # Create 2 host requests by user1
1295 with requests_session(token1) as api:
1296 hr1_id = api.CreateHostRequest(
1297 requests_pb2.CreateHostRequestReq(
1298 host_user_id=host_user.id,
1299 from_date=today_plus_2,
1300 to_date=today_plus_3,
1301 text=valid_request_text(),
1302 )
1303 ).host_request_id
1304 backdate_conversations()
1306 hr2_id = api.CreateHostRequest(
1307 requests_pb2.CreateHostRequestReq(
1308 host_user_id=host_user.id,
1309 from_date=today_plus_2,
1310 to_date=today_plus_3,
1311 text=valid_request_text(),
1312 )
1313 ).host_request_id
1315 # Create 1 host request by user2
1316 with requests_session(token2) as api:
1317 hr3_id = api.CreateHostRequest(
1318 requests_pb2.CreateHostRequestReq(
1319 host_user_id=host_user.id,
1320 from_date=today_plus_2,
1321 to_date=today_plus_3,
1322 text=valid_request_text(),
1323 )
1324 ).host_request_id
1326 # Get moderation state IDs
1327 state1_id, state2_id, state3_id = None, None, None
1328 with session_scope() as session:
1329 hr1 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr1_id)).scalar_one()
1330 hr2 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr2_id)).scalar_one()
1331 hr3 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr3_id)).scalar_one()
1332 state1_id = hr1.moderation_state_id
1333 state2_id = hr2.moderation_state_id
1334 state3_id = hr3.moderation_state_id
1336 # Get all items (should be 3)
1337 with real_moderation_session(super_token) as api:
1338 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1339 assert len(res.queue_items) == 3
1341 # Filter by user1 (should get 2)
1342 with real_moderation_session(super_token) as api:
1343 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user1.id))
1344 assert len(res.queue_items) == 2
1345 assert all(item.moderation_state.author.user_id == user1.id for item in res.queue_items)
1347 # Filter by user2 (should get 1)
1348 with real_moderation_session(super_token) as api:
1349 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user2.id))
1350 assert len(res.queue_items) == 1
1351 assert res.queue_items[0].moderation_state.author.user_id == user2.id
1352 assert res.queue_items[0].moderation_state_id == state3_id
1354 # Filter by non-existent user (should get 0)
1355 with real_moderation_session(super_token) as api:
1356 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=999999))
1357 assert len(res.queue_items) == 0
1360def test_GetModerationQueue_ordering(db):
1361 """Test ordering moderation queue by oldest/newest first"""
1362 super_user, super_token = generate_user(is_superuser=True)
1363 normal_user, user_token = generate_user()
1364 host, _ = generate_user()
1366 # Create 3 host requests
1367 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1368 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1369 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1371 # Set different times: state1 = 3 hours ago, state2 = 2 hours ago, state3 = 1 hour ago
1372 with session_scope() as session:
1373 queue_item1 = session.execute(
1374 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1375 ).scalar_one()
1376 queue_item1.time_created = now() - timedelta(hours=3)
1378 queue_item2 = session.execute(
1379 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1380 ).scalar_one()
1381 queue_item2.time_created = now() - timedelta(hours=2)
1383 queue_item3 = session.execute(
1384 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state3_id)
1385 ).scalar_one()
1386 queue_item3.time_created = now() - timedelta(hours=1)
1388 # Default order (oldest first)
1389 with real_moderation_session(super_token) as api:
1390 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1391 assert len(res.queue_items) == 3
1392 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1393 assert res.queue_items[1].moderation_state_id == state2_id
1394 assert res.queue_items[2].moderation_state_id == state3_id # newest
1396 # Explicit oldest first
1397 with real_moderation_session(super_token) as api:
1398 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=False))
1399 assert len(res.queue_items) == 3
1400 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1401 assert res.queue_items[2].moderation_state_id == state3_id # newest
1403 # Newest first
1404 with real_moderation_session(super_token) as api:
1405 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=True))
1406 assert len(res.queue_items) == 3
1407 assert res.queue_items[0].moderation_state_id == state3_id # newest
1408 assert res.queue_items[1].moderation_state_id == state2_id
1409 assert res.queue_items[2].moderation_state_id == state1_id # oldest
1412def test_GetModerationQueue_pagination_newest_first(db):
1413 """Test pagination with newest_first=True returns different items on each page"""
1414 super_user, super_token = generate_user(is_superuser=True)
1415 normal_user, normal_token = generate_user()
1416 host_user, _ = generate_user()
1418 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1419 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1421 # Create 5 host requests
1422 hr_ids = []
1423 with requests_session(normal_token) as api:
1424 for i in range(5):
1425 hr_id = api.CreateHostRequest(
1426 requests_pb2.CreateHostRequestReq(
1427 host_user_id=host_user.id,
1428 from_date=today_plus_2,
1429 to_date=today_plus_3,
1430 text=valid_request_text(),
1431 )
1432 ).host_request_id
1433 hr_ids.append(hr_id)
1434 backdate_conversations()
1436 # Get moderation state IDs
1437 state_ids = []
1438 with session_scope() as session:
1439 for hr_id in hr_ids:
1440 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
1441 state_ids.append(hr.moderation_state_id)
1443 # Set different times so ordering is deterministic
1444 with session_scope() as session:
1445 for i, state_id in enumerate(state_ids):
1446 queue_item = session.execute(
1447 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
1448 ).scalar_one()
1449 queue_item.time_created = now() - timedelta(hours=5 - i) # oldest first in list
1451 # Get first page (2 items) with newest_first=True, filtered to our user's items
1452 with real_moderation_session(super_token) as api:
1453 res1 = api.GetModerationQueue(
1454 moderation_pb2.GetModerationQueueReq(page_size=2, newest_first=True, item_author_user_id=normal_user.id)
1455 )
1456 assert len(res1.queue_items) == 2
1457 # Should get newest items: state_ids[4], state_ids[3]
1458 assert res1.queue_items[0].moderation_state_id == state_ids[4]
1459 assert res1.queue_items[1].moderation_state_id == state_ids[3]
1460 assert res1.next_page_token # should have more pages
1462 # Get second page using the token
1463 res2 = api.GetModerationQueue(
1464 moderation_pb2.GetModerationQueueReq(
1465 page_size=2, newest_first=True, page_token=res1.next_page_token, item_author_user_id=normal_user.id
1466 )
1467 )
1468 assert len(res2.queue_items) == 2
1469 # Should get next newest items: state_ids[2], state_ids[1]
1470 assert res2.queue_items[0].moderation_state_id == state_ids[2]
1471 assert res2.queue_items[1].moderation_state_id == state_ids[1]
1473 # Pages should not overlap
1474 page1_ids = {item.moderation_state_id for item in res1.queue_items}
1475 page2_ids = {item.moderation_state_id for item in res2.queue_items}
1476 assert page1_ids.isdisjoint(page2_ids), "Pages should not have overlapping items"
1479def test_GetModerationQueue_pagination_ignores_time_created(db):
1480 """Backfilled queue items were given ids out of a separate range, so time_created and id disagree."""
1481 super_user, super_token = generate_user(is_superuser=True)
1482 normal_user, normal_token = generate_user()
1483 host, _ = generate_user()
1485 first_state_id = create_test_host_request_with_moderation(normal_token, host.id)
1486 create_test_host_request_with_moderation(normal_token, host.id)
1487 create_test_host_request_with_moderation(normal_token, host.id)
1489 with session_scope() as session:
1490 item = session.execute(
1491 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == first_state_id)
1492 ).scalar_one()
1493 item.time_created = now() + timedelta(days=1)
1495 paged_ids = []
1496 page_token = ""
1497 with real_moderation_session(super_token) as api:
1498 while True:
1499 res = api.GetModerationQueue(
1500 moderation_pb2.GetModerationQueueReq(page_size=2, page_token=page_token, newest_first=True)
1501 )
1502 paged_ids += [item.queue_item_id for item in res.queue_items]
1503 page_token = res.next_page_token
1504 if not page_token:
1505 break
1507 with session_scope() as session:
1508 expected = list(
1509 session.execute(select(ModerationQueueItem.id).order_by(ModerationQueueItem.id.desc())).scalars().all()
1510 )
1512 assert paged_ids == expected
1515def test_GetModerationLog(db):
1516 """Test getting moderation log for a state via API"""
1517 super_user, super_token = generate_user(is_superuser=True)
1518 moderator, moderator_token = generate_user(is_superuser=True)
1519 normal_user, user_token = generate_user()
1520 host, _ = generate_user()
1522 # Create a real host request
1523 state_id = create_test_host_request_with_moderation(user_token, host.id)
1525 # Perform a moderation action via API
1526 with real_moderation_session(moderator_token) as api:
1527 api.ModerateContent(
1528 moderation_pb2.ModerateContentReq(
1529 moderation_state_id=state_id,
1530 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1531 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1532 reason="Looks good",
1533 )
1534 )
1536 with real_moderation_session(super_token) as api:
1537 res = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
1538 assert len(res.log_entries) == 2 # CREATE + APPROVE
1539 assert res.moderation_state.moderation_state_id == state_id
1540 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1541 # Log entries are in reverse chronological order
1542 assert res.log_entries[0].action == moderation_pb2.MODERATION_ACTION_APPROVE
1543 assert res.log_entries[0].moderator_user_id == moderator.id
1544 assert res.log_entries[0].reason == "Looks good"
1545 assert res.log_entries[1].action == moderation_pb2.MODERATION_ACTION_CREATE
1546 assert res.log_entries[1].moderator_user_id == normal_user.id
1549def test_GetModerationLog_not_found(db):
1550 """Test getting moderation log for non-existent state"""
1551 super_user, super_token = generate_user(is_superuser=True)
1553 with real_moderation_session(super_token) as api:
1554 with pytest.raises(grpc.RpcError) as e:
1555 api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=999999))
1556 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1557 assert e.value.details() == "Moderation state not found."
1560def test_GetModerationState(db):
1561 """Test getting moderation state by object type and ID"""
1562 super_user, super_token = generate_user(is_superuser=True)
1563 user1, token1 = generate_user()
1564 user2, _ = generate_user()
1566 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1567 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1569 with requests_session(token1) as api:
1570 host_request_id = api.CreateHostRequest(
1571 requests_pb2.CreateHostRequestReq(
1572 host_user_id=user2.id,
1573 from_date=today_plus_2,
1574 to_date=today_plus_3,
1575 text=valid_request_text(),
1576 )
1577 ).host_request_id
1579 with real_moderation_session(super_token) as api:
1580 res = api.GetModerationState(
1581 moderation_pb2.GetModerationStateReq(
1582 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1583 object_id=host_request_id,
1584 )
1585 )
1586 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST
1587 assert res.moderation_state.object_id == host_request_id
1588 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1589 assert res.moderation_state.moderation_state_id > 0
1592def test_GetModerationState_not_found(db):
1593 """Test getting moderation state for non-existent object"""
1594 super_user, super_token = generate_user(is_superuser=True)
1596 with real_moderation_session(super_token) as api:
1597 with pytest.raises(grpc.RpcError) as e:
1598 api.GetModerationState(
1599 moderation_pb2.GetModerationStateReq(
1600 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1601 object_id=999999,
1602 )
1603 )
1604 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1605 assert e.value.details() == "Moderation state not found."
1608def test_GetModerationState_unspecified_type(db):
1609 """Test getting moderation state with unspecified object type"""
1610 super_user, super_token = generate_user(is_superuser=True)
1612 with real_moderation_session(super_token) as api:
1613 with pytest.raises(grpc.RpcError) as e:
1614 api.GetModerationState(
1615 moderation_pb2.GetModerationStateReq(
1616 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED,
1617 object_id=123,
1618 )
1619 )
1620 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1621 assert e.value.details() == "Object type must be specified."
1624def test_ModerateContent_approve(db):
1625 """Test approving content via unified moderation API"""
1626 super_user, super_token = generate_user(is_superuser=True)
1627 user1, token1 = generate_user()
1628 user2, _ = generate_user()
1630 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1631 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1633 # Create a host request using the API (which automatically creates moderation state)
1634 with requests_session(token1) as api:
1635 host_request_id = api.CreateHostRequest(
1636 requests_pb2.CreateHostRequestReq(
1637 host_user_id=user2.id,
1638 from_date=today_plus_2,
1639 to_date=today_plus_3,
1640 text=valid_request_text(),
1641 )
1642 ).host_request_id
1644 # Get the moderation state ID
1645 state_id = None
1646 with session_scope() as session:
1647 host_request = session.execute(
1648 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1649 ).scalar_one()
1650 state_id = host_request.moderation_state_id
1652 with real_moderation_session(super_token) as api:
1653 res = api.ModerateContent(
1654 moderation_pb2.ModerateContentReq(
1655 moderation_state_id=state_id,
1656 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1657 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1658 reason="Approved by admin",
1659 )
1660 )
1661 assert res.moderation_state.moderation_state_id == state_id
1662 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1664 # Verify state was updated in database
1665 with session_scope() as session:
1666 state = session.get_one(ModerationState, state_id)
1667 assert state.visibility == ModerationVisibility.visible
1670def test_ModerateContent_not_found(db):
1671 """Test moderating non-existent content"""
1672 super_user, super_token = generate_user(is_superuser=True)
1674 with real_moderation_session(super_token) as api:
1675 with pytest.raises(grpc.RpcError) as e:
1676 api.ModerateContent(
1677 moderation_pb2.ModerateContentReq(
1678 moderation_state_id=999999,
1679 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1680 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1681 reason="Test",
1682 )
1683 )
1684 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1685 assert e.value.details() == "Moderation state not found."
1688def test_ModerateContent_hide(db):
1689 """Test hiding content via unified moderation API"""
1690 super_user, super_token = generate_user(is_superuser=True)
1691 normal_user, user_token = generate_user()
1692 host, _ = generate_user()
1694 # Create a real host request
1695 state_id = create_test_host_request_with_moderation(user_token, host.id)
1697 with real_moderation_session(super_token) as api:
1698 res = api.ModerateContent(
1699 moderation_pb2.ModerateContentReq(
1700 moderation_state_id=state_id,
1701 action=moderation_pb2.MODERATION_ACTION_HIDE,
1702 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1703 reason="Spam content",
1704 )
1705 )
1706 assert res.moderation_state.moderation_state_id == state_id
1707 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_HIDDEN
1709 # Verify state was updated in database
1710 with session_scope() as session:
1711 state = session.get_one(ModerationState, state_id)
1712 assert state.visibility == ModerationVisibility.hidden
1715def test_ModerateContent_shadow(db):
1716 """Test shadowing content via unified moderation API"""
1717 super_user, super_token = generate_user(is_superuser=True)
1718 normal_user, user_token = generate_user()
1719 host, _ = generate_user()
1721 # Create a real host request
1722 state_id = create_test_host_request_with_moderation(user_token, host.id)
1724 with real_moderation_session(super_token) as api:
1725 res = api.ModerateContent(
1726 moderation_pb2.ModerateContentReq(
1727 moderation_state_id=state_id,
1728 action=moderation_pb2.MODERATION_ACTION_HIDE,
1729 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
1730 reason="Needs further review",
1731 )
1732 )
1733 assert res.moderation_state.moderation_state_id == state_id
1734 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1736 # Verify state was updated in database
1737 with session_scope() as session:
1738 state = session.get_one(ModerationState, state_id)
1739 assert state.visibility == ModerationVisibility.shadowed
1742def test_ModerateContent_flag(db):
1743 """Test opening a flag via the FLAG action"""
1744 super_user, super_token = generate_user(is_superuser=True)
1745 user1, token1 = generate_user()
1746 user2, _ = generate_user()
1748 # Create a host request
1749 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1750 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1752 with requests_session(token1) as api:
1753 host_request_id = api.CreateHostRequest(
1754 requests_pb2.CreateHostRequestReq(
1755 host_user_id=user2.id,
1756 from_date=today_plus_2,
1757 to_date=today_plus_3,
1758 text=valid_request_text(),
1759 )
1760 ).host_request_id
1762 # Get the moderation state ID
1763 with session_scope() as session:
1764 host_request = session.execute(
1765 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1766 ).scalar_one()
1767 state_id = host_request.moderation_state_id
1769 with real_moderation_session(super_token) as api:
1770 api.ModerateContent(
1771 moderation_pb2.ModerateContentReq(
1772 moderation_state_id=state_id,
1773 action=moderation_pb2.MODERATION_ACTION_FLAG,
1774 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
1775 reason="Admin flagged for additional review",
1776 priority=5,
1777 )
1778 )
1780 # Verify queue item was created in database with the given priority, plus a log row referencing it
1781 with session_scope() as session:
1782 queue_item = session.execute(
1783 select(ModerationQueueItem)
1784 .where(ModerationQueueItem.moderation_state_id == state_id)
1785 .where(ModerationQueueItem.trigger == ModerationTrigger.moderator_review)
1786 ).scalar_one()
1787 assert queue_item.resolved_by_log_id is None
1788 assert queue_item.priority == 5
1790 flag_log = session.execute(
1791 select(ModerationLog)
1792 .where(ModerationLog.moderation_state_id == state_id)
1793 .where(ModerationLog.action == ModerationAction.flag)
1794 ).scalar_one()
1795 assert flag_log.queue_item_id == queue_item.id
1798def test_ModerateContent_flag_requires_trigger(db):
1799 """FLAG without a trigger is rejected."""
1800 super_user, super_token = generate_user(is_superuser=True)
1801 user1, token1 = generate_user()
1802 user2, _ = generate_user()
1803 state_id = create_test_host_request_with_moderation(token1, user2.id)
1805 with real_moderation_session(super_token) as api:
1806 with pytest.raises(grpc.RpcError) as e:
1807 api.ModerateContent(
1808 moderation_pb2.ModerateContentReq(
1809 moderation_state_id=state_id,
1810 action=moderation_pb2.MODERATION_ACTION_FLAG,
1811 reason="no trigger",
1812 )
1813 )
1814 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1817def _open_queue_item_id(state_id):
1818 with session_scope() as session:
1819 return (
1820 session.execute(
1821 select(ModerationQueueItem)
1822 .where(ModerationQueueItem.moderation_state_id == state_id)
1823 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1824 )
1825 .scalars()
1826 .one()
1827 .id
1828 )
1831def test_ModerateContent_set_priority(db):
1832 """SET_PRIORITY changes a flag's priority and logs it, without touching visibility."""
1833 moderator, mod_token = generate_user(is_superuser=True)
1834 user1, token1 = generate_user()
1835 user2, _ = generate_user()
1836 state_id = create_test_host_request_with_moderation(token1, user2.id)
1837 queue_item_id = _open_queue_item_id(state_id)
1839 with real_moderation_session(mod_token) as api:
1840 api.ModerateContent(
1841 moderation_pb2.ModerateContentReq(
1842 moderation_state_id=state_id,
1843 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY,
1844 queue_item_id=queue_item_id,
1845 priority=10,
1846 reason="bump",
1847 )
1848 )
1850 with session_scope() as session:
1851 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
1852 assert queue_item.priority == 10
1853 assert queue_item.resolved_by_log_id is None # not resolved
1854 # State stays shadowed (visibility untouched)
1855 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.shadowed
1857 log = session.execute(
1858 select(ModerationLog)
1859 .where(ModerationLog.moderation_state_id == state_id)
1860 .where(ModerationLog.action == ModerationAction.set_priority)
1861 ).scalar_one()
1862 assert log.new_priority == 10
1863 assert log.queue_item_id == queue_item_id
1865 # GetModerationQueue surfaces the new priority
1866 with real_moderation_session(mod_token) as api:
1867 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1868 item = next(i for i in res.queue_items if i.queue_item_id == queue_item_id)
1869 assert item.priority == 10
1872def test_ModerateContent_unflag(db):
1873 """UNFLAG resolves a single named flag and does not change visibility."""
1874 moderator, mod_token = generate_user(is_superuser=True)
1875 user1, token1 = generate_user()
1876 user2, _ = generate_user()
1877 state_id = create_test_host_request_with_moderation(token1, user2.id)
1878 queue_item_id = _open_queue_item_id(state_id)
1880 with real_moderation_session(mod_token) as api:
1881 api.ModerateContent(
1882 moderation_pb2.ModerateContentReq(
1883 moderation_state_id=state_id,
1884 action=moderation_pb2.MODERATION_ACTION_UNFLAG,
1885 queue_item_id=queue_item_id,
1886 reason="dismissed",
1887 )
1888 )
1890 with session_scope() as session:
1891 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
1892 assert queue_item.resolved_by_log_id is not None
1893 # Visibility unchanged (still shadowed from creation)
1894 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.shadowed
1895 log = session.get_one(ModerationLog, queue_item.resolved_by_log_id)
1896 assert log.action == ModerationAction.unflag
1897 assert log.queue_item_id == queue_item_id
1900def test_ModerateContent_unflag_requires_queue_item(db):
1901 """UNFLAG without a queue_item_id is rejected."""
1902 moderator, mod_token = generate_user(is_superuser=True)
1903 user1, token1 = generate_user()
1904 user2, _ = generate_user()
1905 state_id = create_test_host_request_with_moderation(token1, user2.id)
1907 with real_moderation_session(mod_token) as api:
1908 with pytest.raises(grpc.RpcError) as e:
1909 api.ModerateContent(
1910 moderation_pb2.ModerateContentReq(
1911 moderation_state_id=state_id,
1912 action=moderation_pb2.MODERATION_ACTION_UNFLAG,
1913 reason="no target",
1914 )
1915 )
1916 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1919def test_ModerateContent_approve_without_clear_flags_leaves_flag_open(db):
1920 """APPROVE without clear_flags changes visibility but leaves open flags in the queue."""
1921 moderator, mod_token = generate_user(is_superuser=True)
1922 user1, token1 = generate_user()
1923 user2, _ = generate_user()
1924 state_id = create_test_host_request_with_moderation(token1, user2.id)
1925 queue_item_id = _open_queue_item_id(state_id)
1927 with real_moderation_session(mod_token) as api:
1928 api.ModerateContent(
1929 moderation_pb2.ModerateContentReq(
1930 moderation_state_id=state_id,
1931 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1932 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1933 reason="visible but still under review",
1934 )
1935 )
1937 with session_scope() as session:
1938 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.visible
1939 assert session.get_one(ModerationQueueItem, queue_item_id).resolved_by_log_id is None
1942def test_ModerateContent_flag_supersede(db):
1943 """FLAG with supersede_queue_item_id resolves the named flag as the new one opens."""
1944 moderator, mod_token = generate_user(is_superuser=True)
1945 user1, token1 = generate_user()
1946 user2, _ = generate_user()
1947 state_id = create_test_host_request_with_moderation(token1, user2.id)
1948 initial_item_id = _open_queue_item_id(state_id)
1950 with real_moderation_session(mod_token) as api:
1951 api.ModerateContent(
1952 moderation_pb2.ModerateContentReq(
1953 moderation_state_id=state_id,
1954 action=moderation_pb2.MODERATION_ACTION_FLAG,
1955 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG,
1956 priority=3,
1957 supersede_queue_item_id=initial_item_id,
1958 reason="machine re-flag",
1959 )
1960 )
1962 with session_scope() as session:
1963 # Old initial_review flag is resolved
1964 assert session.get_one(ModerationQueueItem, initial_item_id).resolved_by_log_id is not None
1965 # Exactly one open flag remains: the new machine_flag at priority 3
1966 open_items = (
1967 session.execute(
1968 select(ModerationQueueItem)
1969 .where(ModerationQueueItem.moderation_state_id == state_id)
1970 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1971 )
1972 .scalars()
1973 .all()
1974 )
1975 assert len(open_items) == 1
1976 assert open_items[0].trigger == ModerationTrigger.machine_flag
1977 assert open_items[0].priority == 3
1980def test_GetModerationQueue_filter_by_priority(db):
1981 """priority_min / priority_max filter the queue by priority range."""
1982 moderator, mod_token = generate_user(is_superuser=True)
1983 user1, token1 = generate_user()
1984 user2, _ = generate_user()
1985 state_id = create_test_host_request_with_moderation(token1, user2.id)
1986 queue_item_id = _open_queue_item_id(state_id)
1988 with real_moderation_session(mod_token) as api:
1989 api.ModerateContent(
1990 moderation_pb2.ModerateContentReq(
1991 moderation_state_id=state_id,
1992 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY,
1993 queue_item_id=queue_item_id,
1994 priority=7,
1995 reason="raise",
1996 )
1997 )
1999 # priority_min excludes it below the threshold, includes at/above
2000 assert len(api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_min=8)).queue_items) == 0
2001 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_min=7))
2002 assert [i.queue_item_id for i in res.queue_items] == [queue_item_id]
2004 # priority_max excludes it above the threshold
2005 assert len(api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_max=6)).queue_items) == 0
2006 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_max=7))
2007 assert [i.queue_item_id for i in res.queue_items] == [queue_item_id]
2010# ============================================================================
2011# Tests for group chat moderation
2012# ============================================================================
2015def test_group_chat_created_with_moderation_state(db):
2016 """Test that group chats are created with moderation state"""
2017 user1, token1 = generate_user()
2018 user2, _ = generate_user()
2019 make_friends(user1, user2)
2021 with conversations_session(token1) as api:
2022 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
2023 group_chat_id = res.group_chat_id
2025 # Verify moderation state was created
2026 with session_scope() as session:
2027 group_chat = session.execute(select(GroupChat).where(GroupChat.conversation_id == group_chat_id)).scalar_one()
2029 assert group_chat.moderation_state.object_type == ModerationObjectType.group_chat
2030 assert group_chat.moderation_state.object_id == group_chat_id
2031 # Group chats start as SHADOWED
2032 assert group_chat.moderation_state.visibility == ModerationVisibility.shadowed
2034 # A moderation queue item should have been created
2035 queue_item = (
2036 session.execute(
2037 select(ModerationQueueItem).where(
2038 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id
2039 )
2040 )
2041 .scalars()
2042 .first()
2043 )
2044 assert queue_item is not None
2045 assert queue_item.trigger == ModerationTrigger.initial_review
2048def test_group_chat_GetModerationState(db):
2049 """Test GetModerationState API for group chats"""
2050 user1, token1 = generate_user()
2051 user2, _ = generate_user()
2052 moderator, mod_token = generate_user(is_superuser=True)
2053 make_friends(user1, user2)
2055 with conversations_session(token1) as api:
2056 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
2057 group_chat_id = res.group_chat_id
2059 # Moderator can look up the moderation state
2060 with real_moderation_session(mod_token) as api:
2061 res = api.GetModerationState(
2062 moderation_pb2.GetModerationStateReq(
2063 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
2064 object_id=group_chat_id,
2065 )
2066 )
2067 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT
2068 assert res.moderation_state.object_id == group_chat_id
2069 # Starts as SHADOWED
2070 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2073def test_group_chat_moderation_hide(db):
2074 """Test that a moderator can hide a group chat and participants can no longer see it"""
2075 user1, token1 = generate_user()
2076 user2, token2 = generate_user()
2077 moderator, mod_token = generate_user(is_superuser=True)
2078 make_friends(user1, user2)
2080 with conversations_session(token1) as api:
2081 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
2082 group_chat_id = res.group_chat_id
2083 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
2085 # First approve the group chat so both users can see it
2086 with real_moderation_session(mod_token) as api:
2087 state_res = api.GetModerationState(
2088 moderation_pb2.GetModerationStateReq(
2089 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
2090 object_id=group_chat_id,
2091 )
2092 )
2093 api.ModerateContent(
2094 moderation_pb2.ModerateContentReq(
2095 moderation_state_id=state_res.moderation_state.moderation_state_id,
2096 action=moderation_pb2.MODERATION_ACTION_APPROVE,
2097 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2098 reason="Approved",
2099 )
2100 )
2102 # Both users can see the chat now
2103 with conversations_session(token1) as api:
2104 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2105 assert len(res.group_chats) == 1
2107 with conversations_session(token2) as api:
2108 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2109 assert len(res.group_chats) == 1
2111 # Moderator hides the group chat
2112 with real_moderation_session(mod_token) as api:
2113 state_res = api.GetModerationState(
2114 moderation_pb2.GetModerationStateReq(
2115 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
2116 object_id=group_chat_id,
2117 )
2118 )
2119 api.ModerateContent(
2120 moderation_pb2.ModerateContentReq(
2121 moderation_state_id=state_res.moderation_state.moderation_state_id,
2122 action=moderation_pb2.MODERATION_ACTION_HIDE,
2123 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2124 reason="Inappropriate content",
2125 )
2126 )
2128 # Neither user can see the chat now
2129 with conversations_session(token1) as api:
2130 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2131 assert len(res.group_chats) == 0
2133 with conversations_session(token2) as api:
2134 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2135 assert len(res.group_chats) == 0
2137 # Trying to get messages returns empty (chat is hidden so no messages visible)
2138 with conversations_session(token1) as api:
2139 res = api.GetGroupChatMessages(conversations_pb2.GetGroupChatMessagesReq(group_chat_id=group_chat_id))
2140 assert len(res.messages) == 0
2143def test_group_chat_moderation_shadow(db):
2144 """Test that shadowing a group chat hides it from non-creator participants"""
2145 user1, token1 = generate_user() # Creator
2146 user2, token2 = generate_user() # Participant
2147 moderator, mod_token = generate_user(is_superuser=True)
2148 make_friends(user1, user2)
2150 with conversations_session(token1) as api:
2151 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
2152 group_chat_id = res.group_chat_id
2153 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
2155 # Moderator shadows the group chat
2156 with real_moderation_session(mod_token) as api:
2157 state_res = api.GetModerationState(
2158 moderation_pb2.GetModerationStateReq(
2159 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
2160 object_id=group_chat_id,
2161 )
2162 )
2163 api.ModerateContent(
2164 moderation_pb2.ModerateContentReq(
2165 moderation_state_id=state_res.moderation_state.moderation_state_id,
2166 action=moderation_pb2.MODERATION_ACTION_HIDE,
2167 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2168 reason="Needs review",
2169 )
2170 )
2172 # Creator can see SHADOWED content in list operations
2173 with conversations_session(token1) as api:
2174 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2175 assert len(res.group_chats) == 1
2176 assert res.group_chats[0].group_chat_id == group_chat_id
2178 # But non-creator participant cannot see it in lists
2179 with conversations_session(token2) as api:
2180 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2181 assert len(res.group_chats) == 0
2183 # Creator can also access it directly via GetGroupChat
2184 with conversations_session(token1) as api:
2185 res = api.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id))
2186 assert res.group_chat_id == group_chat_id
2189# ============================================================================
2190# Tests for auto-approval background job
2191# ============================================================================
2194def test_auto_approve_moderation_queue_disabled_when_zero(db, email_collector: EmailCollector):
2195 """Test that auto-approval is disabled when deadline is 0"""
2196 moderator, mod_token = generate_user(is_superuser=True)
2197 user1, token1 = generate_user()
2198 user2, token2 = generate_user()
2200 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2201 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2203 # Create a host request
2204 with requests_session(token1) as api:
2205 host_request_id = api.CreateHostRequest(
2206 requests_pb2.CreateHostRequestReq(
2207 host_user_id=user2.id,
2208 from_date=today_plus_2,
2209 to_date=today_plus_3,
2210 text=valid_request_text(),
2211 )
2212 ).host_request_id
2214 # No email should have been sent (request is shadowed)
2215 assert email_collector.count_for_recipient(user2.email) == 0
2217 # Ensure deadline is 0 (disabled)
2218 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 0
2220 # Run the job
2221 auto_approve_moderation_queue(empty_pb2.Empty())
2223 # Surfer (author) can see the request via API
2224 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2225 assert res.host_request_id == host_request_id
2227 # Author can see their SHADOWED request in their sent list
2228 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
2229 assert len(res.host_requests) == 1
2230 assert res.host_requests[0].host_request_id == host_request_id
2232 # Host cannot see the request (it's shadowed from them)
2233 with requests_session(token2) as api:
2234 with pytest.raises(grpc.RpcError) as e:
2235 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2236 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2238 # Host doesn't see it in their received list either
2239 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2240 assert len(res.host_requests) == 0
2242 # Moderator can still see the item in the moderation queue
2243 with real_moderation_session(mod_token) as api:
2244 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2245 assert len(res.queue_items) == 1
2246 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW
2248 # Moderator can check the state is still SHADOWED
2249 state_res = api.GetModerationState(
2250 moderation_pb2.GetModerationStateReq(
2251 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2252 object_id=host_request_id,
2253 )
2254 )
2255 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2258def test_auto_approve_moderation_queue_approves_old_items(
2259 db, email_collector: EmailCollector, push_collector: PushCollector
2260):
2261 """Test that auto-approval approves items older than the deadline"""
2262 moderator, mod_token = generate_user(is_superuser=True)
2263 user1, token1 = generate_user()
2264 user2, token2 = generate_user()
2266 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2267 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2269 # Create a host request
2270 with requests_session(token1) as api:
2271 host_request_id = api.CreateHostRequest(
2272 requests_pb2.CreateHostRequestReq(
2273 host_user_id=user2.id,
2274 from_date=today_plus_2,
2275 to_date=today_plus_3,
2276 text=valid_request_text("Test request for auto-approval"),
2277 )
2278 ).host_request_id
2280 # No email sent initially (shadowed)
2281 assert email_collector.count_for_recipient(user2.email) == 0
2283 # Host cannot see the request yet
2284 with requests_session(token2) as api:
2285 with pytest.raises(grpc.RpcError) as e:
2286 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2287 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2289 # Make the queue item appear old by backdating its time_created
2290 with session_scope() as session:
2291 host_request = session.execute(
2292 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
2293 ).scalar_one()
2294 queue_item = session.execute(
2295 select(ModerationQueueItem)
2296 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
2297 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2298 ).scalar_one()
2299 # Backdate the queue item by 2 minutes
2300 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=2)
2302 # Set deadline to 60 seconds (items older than 60 seconds will be auto-approved)
2303 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2304 config.MODERATION_BOT_USER_ID = moderator.id
2306 # Run the job
2307 auto_approve_moderation_queue(empty_pb2.Empty())
2309 # Now host can see the request via API
2310 with requests_session(token2) as api:
2311 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2312 assert res.host_request_id == host_request_id
2313 assert res.latest_message.text.text == valid_request_text("Test request for auto-approval")
2315 # Host sees it in their received list
2316 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2317 assert len(res.host_requests) == 1
2318 assert res.host_requests[0].host_request_id == host_request_id
2320 # Surfer sees it in their sent list
2321 with requests_session(token1) as api:
2322 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
2323 assert len(res.host_requests) == 1
2324 assert res.host_requests[0].host_request_id == host_request_id
2326 with real_moderation_session(mod_token) as api:
2327 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2328 assert len(res.queue_items) == 1
2329 flag_item = res.queue_items[0]
2330 assert flag_item.trigger == moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG
2331 assert flag_item.priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY
2333 state_res = api.GetModerationState(
2334 moderation_pb2.GetModerationStateReq(
2335 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2336 object_id=host_request_id,
2337 )
2338 )
2339 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
2341 log_res = api.GetModerationLog(
2342 moderation_pb2.GetModerationLogReq(moderation_state_id=state_res.moderation_state.moderation_state_id)
2343 )
2344 approve_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_APPROVE]
2345 assert len(approve_entries) == 1
2346 assert "Auto-approved" in approve_entries[0].reason
2347 assert "60 seconds" in approve_entries[0].reason
2348 assert approve_entries[0].moderator_user_id == moderator.id
2350 flag_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_FLAG]
2351 assert len(flag_entries) == 1
2352 assert flag_entries[0].moderator_user_id == moderator.id
2355def test_auto_approve_does_not_approve_recent_items(db, email_collector: EmailCollector):
2356 """Test that auto-approval does not approve items that are newer than the deadline"""
2357 moderator, mod_token = generate_user(is_superuser=True)
2358 user1, token1 = generate_user()
2359 user2, token2 = generate_user()
2361 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2362 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2364 # Create a host request
2365 with requests_session(token1) as api:
2366 host_request_id = api.CreateHostRequest(
2367 requests_pb2.CreateHostRequestReq(
2368 host_user_id=user2.id,
2369 from_date=today_plus_2,
2370 to_date=today_plus_3,
2371 text=valid_request_text(),
2372 )
2373 ).host_request_id
2375 # No email sent (shadowed)
2376 assert email_collector.count_for_recipient(user2.email) == 0
2378 # Set deadline to 1 hour (items older than 1 hour will be auto-approved)
2379 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 3600
2380 config.MODERATION_BOT_USER_ID = moderator.id
2382 # Run the job - the item was just created, so it shouldn't be approved
2383 auto_approve_moderation_queue(empty_pb2.Empty())
2385 # Still no email sent
2386 assert email_collector.count_for_recipient(user2.email) == 0
2388 # Host still cannot see the request
2389 with requests_session(token2) as api:
2390 with pytest.raises(grpc.RpcError) as e:
2391 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2392 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2394 # Not in host's received list
2395 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2396 assert len(res.host_requests) == 0
2398 # Moderator sees it still in queue unresolved
2399 with real_moderation_session(mod_token) as api:
2400 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2401 assert len(res.queue_items) == 1
2403 # State is still SHADOWED
2404 state_res = api.GetModerationState(
2405 moderation_pb2.GetModerationStateReq(
2406 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2407 object_id=host_request_id,
2408 )
2409 )
2410 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2413def test_auto_approve_does_not_approve_already_approved(db):
2414 """Test that auto-approval does not re-approve already visible content"""
2415 moderator, mod_token = generate_user(is_superuser=True)
2416 user1, token1 = generate_user()
2417 user2, token2 = generate_user()
2419 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2420 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2422 # Create a host request
2423 with requests_session(token1) as api:
2424 host_request_id = api.CreateHostRequest(
2425 requests_pb2.CreateHostRequestReq(
2426 host_user_id=user2.id,
2427 from_date=today_plus_2,
2428 to_date=today_plus_3,
2429 text=valid_request_text(),
2430 )
2431 ).host_request_id
2433 # Moderator approves it manually
2434 with real_moderation_session(mod_token) as api:
2435 state_res = api.GetModerationState(
2436 moderation_pb2.GetModerationStateReq(
2437 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2438 object_id=host_request_id,
2439 )
2440 )
2441 state_id = state_res.moderation_state.moderation_state_id
2443 api.ModerateContent(
2444 moderation_pb2.ModerateContentReq(
2445 moderation_state_id=state_id,
2446 action=moderation_pb2.MODERATION_ACTION_APPROVE,
2447 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2448 reason="Approved by moderator",
2449 clear_flags=True,
2450 )
2451 )
2453 # Host can now see it
2454 with requests_session(token2) as api:
2455 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2456 assert res.host_request_id == host_request_id
2458 # Get log count before auto-approval
2459 with real_moderation_session(mod_token) as api:
2460 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2461 log_count_before = len(log_res_before.log_entries)
2463 # Set deadline to 1 second
2464 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1
2465 config.MODERATION_BOT_USER_ID = moderator.id
2467 # Run the job
2468 auto_approve_moderation_queue(empty_pb2.Empty())
2470 # No new log entries should be created (already approved, queue item resolved)
2471 with real_moderation_session(mod_token) as api:
2472 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2473 assert len(log_res_after.log_entries) == log_count_before
2475 # Queue should be empty (item was resolved when moderator approved)
2476 queue_res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2477 assert len(queue_res.queue_items) == 0
2480def test_auto_approve_does_not_approve_moderator_shadowed_items(db):
2481 """Test that auto-approval does not approve items that were explicitly shadowed by a moderator"""
2482 moderator, mod_token = generate_user(is_superuser=True)
2483 user1, token1 = generate_user()
2484 user2, token2 = generate_user()
2486 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2487 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2489 # Create a host request
2490 with requests_session(token1) as api:
2491 host_request_id = api.CreateHostRequest(
2492 requests_pb2.CreateHostRequestReq(
2493 host_user_id=user2.id,
2494 from_date=today_plus_2,
2495 to_date=today_plus_3,
2496 text=valid_request_text(),
2497 )
2498 ).host_request_id
2500 # Moderator explicitly shadows the content (keeping it shadowed but resolving the queue item)
2501 with real_moderation_session(mod_token) as api:
2502 state_res = api.GetModerationState(
2503 moderation_pb2.GetModerationStateReq(
2504 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2505 object_id=host_request_id,
2506 )
2507 )
2508 state_id = state_res.moderation_state.moderation_state_id
2510 # Set to SHADOWED explicitly with clear_flags - this resolves the INITIAL_REVIEW queue item
2511 api.ModerateContent(
2512 moderation_pb2.ModerateContentReq(
2513 moderation_state_id=state_id,
2514 action=moderation_pb2.MODERATION_ACTION_HIDE,
2515 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2516 reason="Keeping shadowed for review",
2517 clear_flags=True,
2518 )
2519 )
2521 # Backdate to ensure it would be old enough for auto-approval
2522 with session_scope() as session:
2523 queue_item = session.execute(
2524 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
2525 ).scalar_one()
2526 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10)
2528 # Set deadline to 1 second
2529 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1
2530 config.MODERATION_BOT_USER_ID = moderator.id
2532 # Get log count before
2533 with real_moderation_session(mod_token) as api:
2534 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2535 log_count_before = len(log_res_before.log_entries)
2537 # Run the job
2538 auto_approve_moderation_queue(empty_pb2.Empty())
2540 # No new log entries - the queue item was resolved when moderator shadowed it
2541 with real_moderation_session(mod_token) as api:
2542 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2543 assert len(log_res_after.log_entries) == log_count_before
2545 # State should still be SHADOWED (not auto-approved to VISIBLE)
2546 state_res = api.GetModerationState(
2547 moderation_pb2.GetModerationStateReq(
2548 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2549 object_id=host_request_id,
2550 )
2551 )
2552 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2554 # Host still cannot see the request
2555 with requests_session(token2) as api:
2556 with pytest.raises(grpc.RpcError) as e:
2557 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2558 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2561def test_auto_approve_skips_shadowed_user_authored_items(db):
2562 """Auto-approval must not promote content authored by a currently-shadowed user."""
2563 moderator, mod_token = generate_user(is_superuser=True)
2564 surfer, surfer_token = generate_user()
2565 host, host_token = generate_user()
2567 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2568 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2570 with requests_session(surfer_token) as api:
2571 host_request_id = api.CreateHostRequest(
2572 requests_pb2.CreateHostRequestReq(
2573 host_user_id=host.id,
2574 from_date=today_plus_2,
2575 to_date=today_plus_3,
2576 text=valid_request_text(),
2577 )
2578 ).host_request_id
2580 # Shadow the surfer after the host request was created
2581 with session_scope() as session:
2582 session.execute(select(User).where(User.id == surfer.id)).scalar_one().shadowed_at = now()
2584 # Backdate the queue item to make it eligible for auto-approval
2585 with session_scope() as session:
2586 host_request = session.execute(
2587 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
2588 ).scalar_one()
2589 queue_item = session.execute(
2590 select(ModerationQueueItem)
2591 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
2592 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2593 ).scalar_one()
2594 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10)
2596 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2597 config.MODERATION_BOT_USER_ID = moderator.id
2599 auto_approve_moderation_queue(empty_pb2.Empty())
2601 # State should remain SHADOWED — the auto-approve job must skip shadowed-user content
2602 with real_moderation_session(mod_token) as api:
2603 state_res = api.GetModerationState(
2604 moderation_pb2.GetModerationStateReq(
2605 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2606 object_id=host_request_id,
2607 )
2608 )
2609 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2611 # Host still cannot see the request
2612 with requests_session(host_token) as api:
2613 with pytest.raises(grpc.RpcError) as e:
2614 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2615 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2617 # But the (shadowed) author can still see their own request
2618 with requests_session(surfer_token) as api:
2619 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2620 assert res.host_request_id == host_request_id
2623def test_auto_approve_preserves_other_open_flags(db):
2624 """Auto-approval supersedes only the INITIAL_REVIEW item; other open flags survive and a high-prio flag is added."""
2625 moderator, mod_token = generate_user(is_superuser=True)
2626 surfer, surfer_token = generate_user()
2627 host, _ = generate_user()
2629 state_id = create_test_host_request_with_moderation(surfer_token, host.id)
2631 with real_moderation_session(mod_token) as api:
2632 api.ModerateContent(
2633 moderation_pb2.ModerateContentReq(
2634 moderation_state_id=state_id,
2635 action=moderation_pb2.MODERATION_ACTION_FLAG,
2636 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
2637 priority=5,
2638 reason="user reported this",
2639 )
2640 )
2642 with session_scope() as session:
2643 initial_item = session.execute(
2644 select(ModerationQueueItem)
2645 .where(ModerationQueueItem.moderation_state_id == state_id)
2646 .where(ModerationQueueItem.trigger == ModerationTrigger.initial_review)
2647 ).scalar_one()
2648 initial_item_id = initial_item.id
2649 initial_item.time_created = datetime.now(initial_item.time_created.tzinfo) - timedelta(minutes=10)
2651 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2652 config.MODERATION_BOT_USER_ID = moderator.id
2654 auto_approve_moderation_queue(empty_pb2.Empty())
2656 with session_scope() as session:
2657 assert (
2658 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.id == initial_item_id))
2659 .scalar_one()
2660 .resolved_by_log_id
2661 is not None
2662 )
2663 assert (
2664 session.execute(select(ModerationState).where(ModerationState.id == state_id)).scalar_one().visibility
2665 == ModerationVisibility.visible
2666 )
2668 open_items = (
2669 session.execute(
2670 select(ModerationQueueItem)
2671 .where(ModerationQueueItem.moderation_state_id == state_id)
2672 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2673 )
2674 .scalars()
2675 .all()
2676 )
2677 open_by_trigger = {item.trigger: item for item in open_items}
2678 assert open_by_trigger.keys() == {ModerationTrigger.user_flag, ModerationTrigger.machine_flag}
2679 assert open_by_trigger[ModerationTrigger.user_flag].priority == 5
2680 assert open_by_trigger[ModerationTrigger.machine_flag].priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY
2683# ============================================================================
2684# Notification Suppression Tests
2685# ============================================================================
2688def test_host_request_message_notifications_suppressed_before_approval(
2689 db, email_collector: EmailCollector, push_collector: PushCollector, moderator
2690):
2691 """
2692 Test that notifications are NOT sent for messages in host requests
2693 that haven't been approved yet.
2694 """
2695 host, host_token = generate_user(complete_profile=True)
2696 surfer, surfer_token = generate_user(complete_profile=True)
2698 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2699 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2701 # Create host request (it starts in SHADOWED state)
2702 with requests_session(surfer_token) as api:
2703 hr_id = api.CreateHostRequest(
2704 requests_pb2.CreateHostRequestReq(
2705 host_user_id=host.id,
2706 from_date=today_plus_2,
2707 to_date=today_plus_3,
2708 text=valid_request_text("Initial request message"),
2709 )
2710 ).host_request_id
2712 # No notifications should have been sent to the host (request is SHADOWED)
2713 assert email_collector.count_for_recipient(host.email) == 0
2714 assert push_collector.count_for_user(host.id) == 0
2716 # Send additional messages BEFORE approval - should NOT generate notifications
2717 with requests_session(surfer_token) as api:
2718 api.SendHostRequestMessage(
2719 requests_pb2.SendHostRequestMessageReq(
2720 host_request_id=hr_id,
2721 text="Follow-up message 1",
2722 )
2723 )
2724 api.SendHostRequestMessage(
2725 requests_pb2.SendHostRequestMessageReq(
2726 host_request_id=hr_id,
2727 text="Follow-up message 2",
2728 )
2729 )
2731 # Host should STILL have no notifications (messages sent while SHADOWED)
2732 assert email_collector.count_for_recipient(host.email) == 0
2733 assert push_collector.count_for_user(host.id) == 0
2735 # Now approve the request
2736 moderator.approve_host_request(hr_id)
2737 email_collector.pop_for_recipient(host.email)
2739 # Host should now have 3 notifications (all deferred notifications are delivered on approval):
2740 # 1. host_request:create (the initial request)
2741 # 2. host_request:message (Follow-up message 1)
2742 # 3. host_request:message (Follow-up message 2)
2743 assert push_collector.count_for_user(host.id) == 3
2744 push = push_collector.pop_for_user(host.id, last=False)
2745 assert push.content.title == f"New host request from {surfer.name}"
2748def test_host_request_status_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2749 """
2750 Test that status change notifications (accept/reject/etc.) are NOT sent
2751 for host requests that haven't been approved yet.
2753 Note: In practice, the host can't even SEE the request to accept/reject it
2754 when it's SHADOWED. But if they somehow did, we still shouldn't notify.
2755 """
2756 host, host_token = generate_user(complete_profile=True)
2757 surfer, surfer_token = generate_user(complete_profile=True)
2759 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2760 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2762 # Create host request
2763 with requests_session(surfer_token) as api:
2764 hr_id = api.CreateHostRequest(
2765 requests_pb2.CreateHostRequestReq(
2766 host_user_id=host.id,
2767 from_date=today_plus_2,
2768 to_date=today_plus_3,
2769 text=valid_request_text(),
2770 )
2771 ).host_request_id
2773 # No notifications should have been sent to the host (request is SHADOWED)
2774 assert push_collector.count_for_user(host.id) == 0
2776 # The surfer can cancel their own request even when SHADOWED
2777 # But this should NOT notify the host since the request isn't approved
2778 with requests_session(surfer_token) as api:
2779 api.RespondHostRequest(
2780 requests_pb2.RespondHostRequestReq(
2781 host_request_id=hr_id,
2782 status=messages_pb2.HOST_REQUEST_STATUS_CANCELLED,
2783 text="Actually, never mind",
2784 )
2785 )
2787 # Host should STILL have no notifications (cancel notification suppressed)
2788 assert push_collector.count_for_user(host.id) == 0
2791def test_host_request_notifications_sent_after_approval(
2792 db, email_collector: EmailCollector, push_collector: PushCollector, moderator
2793):
2794 """
2795 Test that after a host request is approved, all notifications work normally.
2796 """
2797 host, host_token = generate_user(complete_profile=True)
2798 surfer, surfer_token = generate_user(complete_profile=True)
2800 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2801 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2803 # Create and approve host request
2804 with requests_session(surfer_token) as api:
2805 hr_id = api.CreateHostRequest(
2806 requests_pb2.CreateHostRequestReq(
2807 host_user_id=host.id,
2808 from_date=today_plus_2,
2809 to_date=today_plus_3,
2810 text=valid_request_text(),
2811 )
2812 ).host_request_id
2814 moderator.approve_host_request(hr_id)
2816 # Host should have received 1 notification (the approval notification)
2817 email_collector.pop_for_recipient(host.email, last=True)
2818 push_collector.pop_for_user(host.id, last=True)
2820 # Host accepts the request - surfer should be notified
2821 with requests_session(host_token) as api:
2822 api.RespondHostRequest(
2823 requests_pb2.RespondHostRequestReq(
2824 host_request_id=hr_id,
2825 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED,
2826 text="Sure, come on over!",
2827 )
2828 )
2830 # Surfer should have 1 notification (the accept notification)
2831 email_collector.pop_for_recipient(surfer.email, last=True)
2832 push = push_collector.pop_for_user(surfer.id, last=True)
2833 assert push.content.title == f"{host.name} accepted your host request"
2835 # Surfer confirms - host should be notified
2836 with requests_session(surfer_token) as api:
2837 api.RespondHostRequest(
2838 requests_pb2.RespondHostRequestReq(
2839 host_request_id=hr_id,
2840 status=messages_pb2.HOST_REQUEST_STATUS_CONFIRMED,
2841 text="See you then!",
2842 )
2843 )
2845 # Host should now have received the confirmation notifications
2846 email_collector.pop_for_recipient(host.email, last=True)
2847 push = push_collector.pop_for_user(host.id, last=True)
2848 assert push.content.title == f"{surfer.name} confirmed their host request"
2851def test_group_chat_message_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2852 """
2853 Test that notifications are NOT sent for messages in group chats
2854 that haven't been approved yet.
2855 """
2856 user1, token1 = generate_user(complete_profile=True)
2857 user2, token2 = generate_user(complete_profile=True)
2859 # Create a group chat (starts in SHADOWED state)
2860 with conversations_session(token1) as api:
2861 res = api.CreateGroupChat(
2862 conversations_pb2.CreateGroupChatReq(
2863 recipient_user_ids=[user2.id],
2864 )
2865 )
2866 gc_id = res.group_chat_id
2868 # Verify initial state
2869 with session_scope() as session:
2870 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2871 assert gc.moderation_state.visibility == ModerationVisibility.shadowed
2873 # No notifications should have been sent yet (chat is SHADOWED)
2874 assert push_collector.count_for_user(user2.id) == 0
2876 # Send messages BEFORE approval
2877 with conversations_session(token1) as api:
2878 api.SendMessage(
2879 conversations_pb2.SendMessageReq(
2880 group_chat_id=gc_id,
2881 text="Hello before approval",
2882 )
2883 )
2885 # Process the queued notification job
2886 while process_job():
2887 pass
2889 # User2 should STILL have no notifications (chat is SHADOWED)
2890 assert push_collector.count_for_user(user2.id) == 0
2892 # Now approve the group chat
2893 moderator.approve_group_chat(gc_id)
2895 # Process the queued notification jobs from approval
2896 while process_job():
2897 pass
2899 # Verify moderation state after approval
2900 with session_scope() as session:
2901 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2902 assert gc.moderation_state.visibility == ModerationVisibility.visible
2904 # User2 should have received 1 notification for the first message sent before approval
2905 push = push_collector.pop_for_user(user2.id, last=True)
2906 assert push.content.title == user1.name
2907 assert push.content.body == "Hello before approval"
2909 # Send a message AFTER approval
2910 with conversations_session(token1) as api:
2911 api.SendMessage(
2912 conversations_pb2.SendMessageReq(
2913 group_chat_id=gc_id,
2914 text="Hello after approval",
2915 )
2916 )
2918 # Process the queued notification job
2919 while process_job():
2920 pass
2922 # User2 should have received another notification
2923 assert push_collector.count_for_user(user2.id) == 1
2926def test_event_moderation_state_content(db):
2927 """Test that event moderation state content includes both title and description"""
2928 super_user, super_token = generate_user(is_superuser=True)
2929 user, token = generate_user()
2931 with session_scope() as session:
2932 create_community(session, 0, 2, "Community", [user], [], None)
2934 start_time = now() + timedelta(hours=2)
2935 end_time = start_time + timedelta(hours=3)
2937 with events_session(token) as api:
2938 res = api.CreateEvent(
2939 events_pb2.CreateEventReq(
2940 title="My Event Title",
2941 content="My event description.",
2942 photo_key=None,
2943 location=events_pb2.EventLocation(
2944 address="Near Null Island",
2945 lat=0.1,
2946 lng=0.2,
2947 ),
2948 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time),
2949 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time),
2950 )
2951 )
2952 event_id = res.event_id
2954 with session_scope() as session:
2955 occurrence = session.execute(select(EventOccurrence).where(EventOccurrence.id == event_id)).scalar_one()
2956 state_id = occurrence.moderation_state_id
2958 with real_moderation_session(super_token) as api:
2959 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
2960 event_items = [
2961 item
2962 for item in res.queue_items
2963 if item.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE
2964 ]
2965 assert len(event_items) == 1
2966 assert event_items[0].moderation_state.content == "My Event Title\n\nMy event description."
2969# ============================================================================
2970# Tests for SetUserContentVisibility
2971# ============================================================================
2974def _get_moderation_state(session, object_type, object_id):
2975 return session.execute(
2976 select(ModerationState)
2977 .where(ModerationState.object_type == object_type)
2978 .where(ModerationState.object_id == object_id)
2979 ).scalar_one()
2982def test_SetUserContentVisibility_host_request(db):
2983 super_user, super_token = generate_user(is_superuser=True)
2984 surfer, surfer_token = generate_user()
2985 host, _ = generate_user()
2987 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2988 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2989 with requests_session(surfer_token) as api:
2990 hr_id = api.CreateHostRequest(
2991 requests_pb2.CreateHostRequestReq(
2992 host_user_id=host.id,
2993 from_date=today_plus_2,
2994 to_date=today_plus_3,
2995 text=valid_request_text(),
2996 )
2997 ).host_request_id
2999 with real_moderation_session(super_token) as api:
3000 res = api.SetUserContentVisibility(
3001 moderation_pb2.SetUserContentVisibilityReq(
3002 user_id=surfer.id,
3003 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3004 )
3005 )
3006 # Already shadowed by default — no-op
3007 assert res.updated_count == 0
3009 with real_moderation_session(super_token) as api:
3010 res = api.SetUserContentVisibility(
3011 moderation_pb2.SetUserContentVisibilityReq(
3012 user_id=surfer.id,
3013 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3014 reason="policy violation",
3015 )
3016 )
3017 assert res.updated_count == 1
3019 with session_scope() as session:
3020 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3021 assert state.visibility == ModerationVisibility.hidden
3023 log_entries = (
3024 session.execute(
3025 select(ModerationLog)
3026 .where(ModerationLog.moderation_state_id == state.id)
3027 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc())
3028 )
3029 .scalars()
3030 .all()
3031 )
3032 # create log + bulk update log
3033 assert len(log_entries) == 2
3034 assert log_entries[-1].new_visibility == ModerationVisibility.hidden
3035 assert log_entries[-1].moderator_user_id == super_user.id
3036 assert log_entries[-1].reason == "policy violation"
3039def test_SetUserContentVisibility_group_chat(db):
3040 super_user, super_token = generate_user(is_superuser=True)
3041 creator, creator_token = generate_user()
3042 other, _ = generate_user()
3043 make_friends(creator, other)
3045 with conversations_session(creator_token) as api:
3046 gc_id = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[other.id])).group_chat_id
3048 with real_moderation_session(super_token) as api:
3049 api.SetUserContentVisibility(
3050 moderation_pb2.SetUserContentVisibilityReq(
3051 user_id=creator.id,
3052 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3053 )
3054 )
3056 with session_scope() as session:
3057 state = _get_moderation_state(session, ModerationObjectType.group_chat, gc_id)
3058 assert state.visibility == ModerationVisibility.hidden
3061def test_SetUserContentVisibility_event_occurrence(db):
3062 super_user, super_token = generate_user(is_superuser=True)
3063 creator, creator_token = generate_user()
3065 with session_scope() as session:
3066 create_community(session, 0, 2, "Community", [creator], [], None)
3068 start_time = now() + timedelta(hours=2)
3069 end_time = start_time + timedelta(hours=3)
3070 with events_session(creator_token) as api:
3071 event_id = api.CreateEvent(
3072 events_pb2.CreateEventReq(
3073 title="Event",
3074 content="Event description.",
3075 photo_key=None,
3076 location=events_pb2.EventLocation(
3077 address="Near Null Island",
3078 lat=0.1,
3079 lng=0.2,
3080 ),
3081 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time),
3082 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time),
3083 )
3084 ).event_id
3086 with real_moderation_session(super_token) as api:
3087 res = api.SetUserContentVisibility(
3088 moderation_pb2.SetUserContentVisibilityReq(
3089 user_id=creator.id,
3090 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3091 )
3092 )
3093 assert res.updated_count == 1
3095 with session_scope() as session:
3096 state = _get_moderation_state(session, ModerationObjectType.event_occurrence, event_id)
3097 assert state.visibility == ModerationVisibility.hidden
3100def test_SetUserContentVisibility_friend_request(db):
3101 super_user, super_token = generate_user(is_superuser=True)
3102 sender, sender_token = generate_user()
3103 recipient, _ = generate_user()
3105 with api_session(sender_token) as api:
3106 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=recipient.id))
3108 with session_scope() as session:
3109 fr_id = session.execute(
3110 select(FriendRelationship.id).where(FriendRelationship.from_user_id == sender.id)
3111 ).scalar_one()
3113 with real_moderation_session(super_token) as api:
3114 res = api.SetUserContentVisibility(
3115 moderation_pb2.SetUserContentVisibilityReq(
3116 user_id=sender.id,
3117 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3118 )
3119 )
3120 assert res.updated_count == 1
3122 with session_scope() as session:
3123 state = _get_moderation_state(session, ModerationObjectType.friend_request, fr_id)
3124 assert state.visibility == ModerationVisibility.hidden
3127def test_SetUserContentVisibility_round_trip(db):
3128 super_user, super_token = generate_user(is_superuser=True)
3129 surfer, surfer_token = generate_user()
3130 host, _ = generate_user()
3132 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3133 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3134 with requests_session(surfer_token) as api:
3135 hr_id = api.CreateHostRequest(
3136 requests_pb2.CreateHostRequestReq(
3137 host_user_id=host.id,
3138 from_date=today_plus_2,
3139 to_date=today_plus_3,
3140 text=valid_request_text(),
3141 )
3142 ).host_request_id
3144 with real_moderation_session(super_token) as api:
3145 api.SetUserContentVisibility(
3146 moderation_pb2.SetUserContentVisibilityReq(
3147 user_id=surfer.id,
3148 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3149 reason="first",
3150 )
3151 )
3152 api.SetUserContentVisibility(
3153 moderation_pb2.SetUserContentVisibilityReq(
3154 user_id=surfer.id,
3155 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3156 reason="second",
3157 )
3158 )
3160 with session_scope() as session:
3161 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3162 assert state.visibility == ModerationVisibility.visible
3164 bulk_log_entries = (
3165 session.execute(
3166 select(ModerationLog)
3167 .where(ModerationLog.moderation_state_id == state.id)
3168 .where(ModerationLog.reason.in_(("first", "second")))
3169 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc())
3170 )
3171 .scalars()
3172 .all()
3173 )
3174 assert [entry.new_visibility for entry in bulk_log_entries] == [
3175 ModerationVisibility.hidden,
3176 ModerationVisibility.visible,
3177 ]
3180def test_SetUserContentVisibility_resolves_queue_items(db):
3181 super_user, super_token = generate_user(is_superuser=True)
3182 surfer, surfer_token = generate_user()
3183 host, _ = generate_user()
3185 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3186 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3187 with requests_session(surfer_token) as api:
3188 hr_id = api.CreateHostRequest(
3189 requests_pb2.CreateHostRequestReq(
3190 host_user_id=host.id,
3191 from_date=today_plus_2,
3192 to_date=today_plus_3,
3193 text=valid_request_text(),
3194 )
3195 ).host_request_id
3197 with session_scope() as session:
3198 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3199 queue_item = session.execute(
3200 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id)
3201 ).scalar_one()
3202 assert queue_item.resolved_by_log_id is None
3204 with real_moderation_session(super_token) as api:
3205 api.SetUserContentVisibility(
3206 moderation_pb2.SetUserContentVisibilityReq(
3207 user_id=surfer.id,
3208 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3209 )
3210 )
3212 with session_scope() as session:
3213 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3214 queue_item = session.execute(
3215 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id)
3216 ).scalar_one()
3217 assert queue_item.resolved_by_log_id is not None
3220def test_SetUserContentVisibility_noop_when_matches(db):
3221 super_user, super_token = generate_user(is_superuser=True)
3222 surfer, surfer_token = generate_user()
3223 host, _ = generate_user()
3225 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3226 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3227 with requests_session(surfer_token) as api:
3228 api.CreateHostRequest(
3229 requests_pb2.CreateHostRequestReq(
3230 host_user_id=host.id,
3231 from_date=today_plus_2,
3232 to_date=today_plus_3,
3233 text=valid_request_text(),
3234 )
3235 )
3237 with session_scope() as session:
3238 log_count_before = len(session.execute(select(ModerationLog)).scalars().all())
3240 with real_moderation_session(super_token) as api:
3241 res = api.SetUserContentVisibility(
3242 moderation_pb2.SetUserContentVisibilityReq(
3243 user_id=surfer.id,
3244 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3245 )
3246 )
3247 assert res.updated_count == 0
3249 with session_scope() as session:
3250 log_count_after = len(session.execute(select(ModerationLog)).scalars().all())
3251 assert log_count_after == log_count_before
3254def test_SetUserContentVisibility_unspecified_rejected(db):
3255 super_user, super_token = generate_user(is_superuser=True)
3256 target, _ = generate_user()
3258 with real_moderation_session(super_token) as api:
3259 with pytest.raises(grpc.RpcError) as e:
3260 api.SetUserContentVisibility(
3261 moderation_pb2.SetUserContentVisibilityReq(
3262 user_id=target.id,
3263 visibility=moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED,
3264 )
3265 )
3266 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
3269def test_SetUserContentVisibility_non_admin_rejected(db):
3270 normal_user, normal_token = generate_user()
3271 target, _ = generate_user()
3273 with real_moderation_session(normal_token) as api:
3274 with pytest.raises(grpc.RpcError) as e:
3275 api.SetUserContentVisibility(
3276 moderation_pb2.SetUserContentVisibilityReq(
3277 user_id=target.id,
3278 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3279 )
3280 )
3281 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED
3284def test_SetUserContentVisibility_writes_admin_action(db):
3285 super_user, super_token = generate_user(is_superuser=True)
3286 surfer, surfer_token = generate_user()
3287 host, _ = generate_user()
3289 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3290 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3291 with requests_session(surfer_token) as api:
3292 api.CreateHostRequest(
3293 requests_pb2.CreateHostRequestReq(
3294 host_user_id=host.id,
3295 from_date=today_plus_2,
3296 to_date=today_plus_3,
3297 text=valid_request_text(),
3298 )
3299 )
3301 with real_moderation_session(super_token) as api:
3302 api.SetUserContentVisibility(
3303 moderation_pb2.SetUserContentVisibilityReq(
3304 user_id=surfer.id,
3305 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3306 reason="bulk hide",
3307 )
3308 )
3310 with session_scope() as session:
3311 actions = (
3312 session.execute(
3313 select(AdminAction)
3314 .where(AdminAction.target_user_id == surfer.id)
3315 .where(AdminAction.action_type == "set_user_content_visibility")
3316 )
3317 .scalars()
3318 .all()
3319 )
3320 assert len(actions) == 1
3321 assert actions[0].admin_user_id == super_user.id
3322 assert actions[0].tag == "hidden"
3323 assert actions[0].note == "bulk hide"
3326def test_SetUserContentVisibility_only_touches_target(db):
3327 super_user, super_token = generate_user(is_superuser=True)
3328 target, target_token = generate_user()
3329 other, other_token = generate_user()
3330 host, _ = generate_user()
3332 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3333 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3335 with requests_session(target_token) as api:
3336 target_hr_id = api.CreateHostRequest(
3337 requests_pb2.CreateHostRequestReq(
3338 host_user_id=host.id,
3339 from_date=today_plus_2,
3340 to_date=today_plus_3,
3341 text=valid_request_text(),
3342 )
3343 ).host_request_id
3345 with requests_session(other_token) as api:
3346 other_hr_id = api.CreateHostRequest(
3347 requests_pb2.CreateHostRequestReq(
3348 host_user_id=host.id,
3349 from_date=today_plus_2,
3350 to_date=today_plus_3,
3351 text=valid_request_text(),
3352 )
3353 ).host_request_id
3355 with real_moderation_session(super_token) as api:
3356 res = api.SetUserContentVisibility(
3357 moderation_pb2.SetUserContentVisibilityReq(
3358 user_id=target.id,
3359 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3360 )
3361 )
3362 assert res.updated_count == 1
3364 with session_scope() as session:
3365 target_state = _get_moderation_state(session, ModerationObjectType.host_request, target_hr_id)
3366 other_state = _get_moderation_state(session, ModerationObjectType.host_request, other_hr_id)
3367 assert target_state.visibility == ModerationVisibility.hidden
3368 assert other_state.visibility == ModerationVisibility.shadowed
3371def test_SetUserContentVisibility_user_not_found(db):
3372 super_user, super_token = generate_user(is_superuser=True)
3374 with real_moderation_session(super_token) as api:
3375 with pytest.raises(grpc.RpcError) as e:
3376 api.SetUserContentVisibility(
3377 moderation_pb2.SetUserContentVisibilityReq(
3378 user_id=999999,
3379 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3380 )
3381 )
3382 assert e.value.code() == grpc.StatusCode.NOT_FOUND
3385def test_SetUserContentVisibility_from_visibility_filter(db):
3386 super_user, super_token = generate_user(is_superuser=True)
3387 surfer, surfer_token = generate_user()
3388 host, _ = generate_user()
3390 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3391 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3392 with requests_session(surfer_token) as api:
3393 hr_id = api.CreateHostRequest(
3394 requests_pb2.CreateHostRequestReq(
3395 host_user_id=host.id,
3396 from_date=today_plus_2,
3397 to_date=today_plus_3,
3398 text=valid_request_text(),
3399 )
3400 ).host_request_id
3402 with real_moderation_session(super_token) as api:
3403 api.SetUserContentVisibility(
3404 moderation_pb2.SetUserContentVisibilityReq(
3405 user_id=surfer.id,
3406 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3407 )
3408 )
3410 with real_moderation_session(super_token) as api:
3411 res = api.SetUserContentVisibility(
3412 moderation_pb2.SetUserContentVisibilityReq(
3413 user_id=surfer.id,
3414 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3415 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_SHADOWED],
3416 )
3417 )
3418 assert res.updated_count == 0
3420 with session_scope() as session:
3421 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3422 assert state.visibility == ModerationVisibility.visible
3424 with real_moderation_session(super_token) as api:
3425 res = api.SetUserContentVisibility(
3426 moderation_pb2.SetUserContentVisibilityReq(
3427 user_id=surfer.id,
3428 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3429 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_VISIBLE],
3430 )
3431 )
3432 assert res.updated_count == 1
3434 with session_scope() as session:
3435 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3436 assert state.visibility == ModerationVisibility.shadowed
3439def test_SetUserContentVisibility_from_visibility_multi(db):
3440 super_user, super_token = generate_user(is_superuser=True)
3441 surfer, surfer_token = generate_user()
3442 host1, _ = generate_user()
3443 host2, _ = generate_user()
3445 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3446 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3447 with requests_session(surfer_token) as api:
3448 hr1_id = api.CreateHostRequest(
3449 requests_pb2.CreateHostRequestReq(
3450 host_user_id=host1.id,
3451 from_date=today_plus_2,
3452 to_date=today_plus_3,
3453 text=valid_request_text(),
3454 )
3455 ).host_request_id
3456 hr2_id = api.CreateHostRequest(
3457 requests_pb2.CreateHostRequestReq(
3458 host_user_id=host2.id,
3459 from_date=today_plus_2,
3460 to_date=today_plus_3,
3461 text=valid_request_text(),
3462 )
3463 ).host_request_id
3465 with session_scope() as session:
3466 state1_id = _get_moderation_state(session, ModerationObjectType.host_request, hr1_id).id
3468 with real_moderation_session(super_token) as api:
3469 api.ModerateContent(
3470 moderation_pb2.ModerateContentReq(
3471 moderation_state_id=state1_id,
3472 action=moderation_pb2.MODERATION_ACTION_APPROVE,
3473 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3474 )
3475 )
3477 with real_moderation_session(super_token) as api:
3478 res = api.SetUserContentVisibility(
3479 moderation_pb2.SetUserContentVisibilityReq(
3480 user_id=surfer.id,
3481 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3482 from_visibility=[
3483 moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3484 moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3485 ],
3486 )
3487 )
3488 assert res.updated_count == 2
3490 with session_scope() as session:
3491 state1 = _get_moderation_state(session, ModerationObjectType.host_request, hr1_id)
3492 state2 = _get_moderation_state(session, ModerationObjectType.host_request, hr2_id)
3493 assert state1.visibility == ModerationVisibility.hidden
3494 assert state2.visibility == ModerationVisibility.hidden
3497def test_SetUserContentVisibility_from_visibility_empty_is_any(db):
3498 super_user, super_token = generate_user(is_superuser=True)
3499 surfer, surfer_token = generate_user()
3500 host, _ = generate_user()
3502 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3503 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3504 with requests_session(surfer_token) as api:
3505 hr_id = api.CreateHostRequest(
3506 requests_pb2.CreateHostRequestReq(
3507 host_user_id=host.id,
3508 from_date=today_plus_2,
3509 to_date=today_plus_3,
3510 text=valid_request_text(),
3511 )
3512 ).host_request_id
3514 with real_moderation_session(super_token) as api:
3515 res = api.SetUserContentVisibility(
3516 moderation_pb2.SetUserContentVisibilityReq(
3517 user_id=surfer.id,
3518 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3519 from_visibility=[],
3520 )
3521 )
3522 assert res.updated_count == 1
3524 with session_scope() as session:
3525 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3526 assert state.visibility == ModerationVisibility.hidden
3529def test_SetUserContentVisibility_from_visibility_unspecified_rejected(db):
3530 super_user, super_token = generate_user(is_superuser=True)
3531 target, _ = generate_user()
3533 with real_moderation_session(super_token) as api:
3534 with pytest.raises(grpc.RpcError) as e:
3535 api.SetUserContentVisibility(
3536 moderation_pb2.SetUserContentVisibilityReq(
3537 user_id=target.id,
3538 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3539 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED],
3540 )
3541 )
3542 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
3545def _content_state_ids(res: moderation_pb2.ListModerationStatesRes) -> list[int]:
3546 """Every user has a moderation state, so listings mix them in with the content states."""
3547 return [
3548 s.moderation_state_id
3549 for s in res.moderation_states
3550 if s.object_type != moderation_pb2.MODERATION_OBJECT_TYPE_USER
3551 ]
3554def test_ListModerationStates_with_no_content(db):
3555 super_user, super_token = generate_user(is_superuser=True)
3557 with real_moderation_session(super_token) as api:
3558 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq())
3559 assert [s.object_id for s in res.moderation_states] == [super_user.id]
3560 assert _content_state_ids(res) == []
3561 assert res.next_page_token == ""
3564def test_ListModerationStates_returns_states_chronologically(db):
3565 super_user, super_token = generate_user(is_superuser=True)
3566 surfer, surfer_token = generate_user()
3567 host, _ = generate_user()
3569 state1_id = create_test_host_request_with_moderation(surfer_token, host.id)
3570 state2_id = create_test_host_request_with_moderation(surfer_token, host.id)
3571 state3_id = create_test_host_request_with_moderation(surfer_token, host.id)
3573 with real_moderation_session(super_token) as api:
3574 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq())
3575 assert _content_state_ids(res) == [state1_id, state2_id, state3_id]
3577 res_newest = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(newest_first=True))
3578 assert _content_state_ids(res_newest) == [state3_id, state2_id, state1_id]
3581def test_ListModerationStates_filter_by_author(db):
3582 super_user, super_token = generate_user(is_superuser=True)
3583 surfer1, surfer1_token = generate_user()
3584 surfer2, surfer2_token = generate_user()
3585 host, _ = generate_user()
3587 state1_id = create_test_host_request_with_moderation(surfer1_token, host.id)
3588 state2_id = create_test_host_request_with_moderation(surfer2_token, host.id)
3589 state3_id = create_test_host_request_with_moderation(surfer1_token, host.id)
3591 with real_moderation_session(super_token) as api:
3592 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer1.id))
3593 assert set(_content_state_ids(res)) == {state1_id, state3_id}
3594 user_states = [s for s in res.moderation_states if s.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_USER]
3595 assert [s.object_id for s in user_states] == [surfer1.id]
3597 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer2.id))
3598 assert _content_state_ids(res) == [state2_id]
3601def test_ListModerationStates_pagination(db):
3602 super_user, super_token = generate_user(is_superuser=True)
3603 surfer, surfer_token = generate_user()
3604 host, _ = generate_user()
3606 for _ in range(3):
3607 create_test_host_request_with_moderation(surfer_token, host.id)
3609 with real_moderation_session(super_token) as api:
3610 # 3 users plus 3 host requests
3611 all_ids = [
3612 s.moderation_state_id
3613 for s in api.ListModerationStates(moderation_pb2.ListModerationStatesReq()).moderation_states
3614 ]
3615 assert len(all_ids) == 6
3617 paged_ids = []
3618 page_token = ""
3619 while True:
3620 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(page_size=2, page_token=page_token))
3621 assert len(res.moderation_states) == 2
3622 paged_ids += [s.moderation_state_id for s in res.moderation_states]
3623 page_token = res.next_page_token
3624 if not page_token:
3625 break
3627 assert paged_ids == all_ids
3630def test_ListModerationStates_pagination_ignores_created(db):
3631 """Backfilled states carry the migration's timestamp, so created and id disagree on their order."""
3632 super_user, super_token = generate_user(is_superuser=True)
3633 surfer, surfer_token = generate_user()
3634 host, _ = generate_user()
3636 first_state_id = create_test_host_request_with_moderation(surfer_token, host.id)
3637 create_test_host_request_with_moderation(surfer_token, host.id)
3638 create_test_host_request_with_moderation(surfer_token, host.id)
3640 with session_scope() as session:
3641 state = session.execute(select(ModerationState).where(ModerationState.id == first_state_id)).scalar_one()
3642 state.created = now() + timedelta(days=1)
3644 paged_ids = []
3645 page_token = ""
3646 with real_moderation_session(super_token) as api:
3647 while True:
3648 res = api.ListModerationStates(
3649 moderation_pb2.ListModerationStatesReq(page_size=2, page_token=page_token, newest_first=True)
3650 )
3651 paged_ids += [s.moderation_state_id for s in res.moderation_states]
3652 page_token = res.next_page_token
3653 if not page_token:
3654 break
3656 with session_scope() as session:
3657 expected = list(session.execute(select(ModerationState.id).order_by(ModerationState.id.desc())).scalars().all())
3659 assert paged_ids == expected
3662# ============================================================================
3663# Tests for user account moderation states
3664# ============================================================================
3667def test_user_moderation_state_created_with_the_account(db):
3668 user, _ = generate_user()
3670 with session_scope() as session:
3671 state = session.execute(
3672 select(ModerationState)
3673 .where(ModerationState.object_type == ModerationObjectType.user)
3674 .where(ModerationState.object_id == user.id)
3675 ).scalar_one()
3677 assert state.visibility is None
3678 assert session.execute(select(User.moderation_state_id).where(User.id == user.id)).scalar_one() == state.id
3680 log_entries = (
3681 session.execute(select(ModerationLog).where(ModerationLog.moderation_state_id == state.id)).scalars().all()
3682 )
3683 assert len(log_entries) == 1
3684 assert log_entries[0].action == ModerationAction.create
3685 assert log_entries[0].moderator_user_id == user.id
3687 queue_items = (
3688 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id))
3689 .scalars()
3690 .all()
3691 )
3692 assert queue_items == []
3695def test_GetModerationState_for_user(db):
3696 super_user, super_token = generate_user(is_superuser=True)
3697 user, _ = generate_user()
3699 with real_moderation_session(super_token) as api:
3700 state = api.GetModerationState(
3701 moderation_pb2.GetModerationStateReq(
3702 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER,
3703 object_id=user.id,
3704 )
3705 ).moderation_state
3706 assert state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_USER
3707 assert state.object_id == user.id
3708 assert state.author_user_id == user.id
3709 assert state.author.user_id == user.id
3710 assert state.content == f"@{user.username} / {user.id}"
3711 assert state.visibility == moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED
3714def test_GetModerationState_for_nonexistent_user(db):
3715 super_user, super_token = generate_user(is_superuser=True)
3717 with real_moderation_session(super_token) as api:
3718 with pytest.raises(grpc.RpcError) as e:
3719 api.GetModerationState(
3720 moderation_pb2.GetModerationStateReq(
3721 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER,
3722 object_id=999999,
3723 )
3724 )
3725 assert e.value.code() == grpc.StatusCode.NOT_FOUND
3728def test_flag_user_shows_up_in_queue_and_author_filters(db):
3729 super_user, super_token = generate_user(is_superuser=True)
3730 surfer, surfer_token = generate_user()
3731 host, _ = generate_user()
3732 other, _ = generate_user()
3734 content_state_id = create_test_host_request_with_moderation(surfer_token, host.id)
3736 with real_moderation_session(super_token) as api:
3737 user_state_id = api.GetModerationState(
3738 moderation_pb2.GetModerationStateReq(
3739 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER,
3740 object_id=surfer.id,
3741 )
3742 ).moderation_state.moderation_state_id
3744 api.ModerateContent(
3745 moderation_pb2.ModerateContentReq(
3746 moderation_state_id=user_state_id,
3747 action=moderation_pb2.MODERATION_ACTION_FLAG,
3748 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
3749 reason="Reported for spamming hosts",
3750 )
3751 )
3753 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=surfer.id))
3754 assert {item.moderation_state_id for item in res.queue_items} == {content_state_id, user_state_id}
3755 user_item = next(item for item in res.queue_items if item.moderation_state_id == user_state_id)
3756 assert user_item.trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG
3757 assert user_item.reason == "Reported for spamming hosts"
3758 assert user_item.moderation_state.author.user_id == surfer.id
3760 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=other.id))
3761 assert len(res.queue_items) == 0
3763 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer.id))
3764 assert {s.moderation_state_id for s in res.moderation_states} == {content_state_id, user_state_id}
3766 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=other.id))
3767 assert [s.object_id for s in res.moderation_states] == [other.id]
3770@pytest.mark.parametrize("action", [moderation_pb2.MODERATION_ACTION_APPROVE, moderation_pb2.MODERATION_ACTION_HIDE])
3771def test_ModerateContent_cannot_set_visibility_on_user_state(db, action):
3772 super_user, super_token = generate_user(is_superuser=True)
3773 user, _ = generate_user()
3775 with real_moderation_session(super_token) as api:
3776 user_state_id = api.GetModerationState(
3777 moderation_pb2.GetModerationStateReq(
3778 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER,
3779 object_id=user.id,
3780 )
3781 ).moderation_state.moderation_state_id
3783 with pytest.raises(grpc.RpcError) as e:
3784 api.ModerateContent(
3785 moderation_pb2.ModerateContentReq(
3786 moderation_state_id=user_state_id,
3787 action=action,
3788 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3789 )
3790 )
3791 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
3793 with session_scope() as session:
3794 state = session.execute(select(ModerationState).where(ModerationState.id == user_state_id)).scalar_one()
3795 assert state.visibility is None
3798def test_SetUserContentVisibility_leaves_user_state_alone(db):
3799 super_user, super_token = generate_user(is_superuser=True)
3800 surfer, surfer_token = generate_user()
3801 host, _ = generate_user()
3803 content_state_id = create_test_host_request_with_moderation(surfer_token, host.id)
3805 with real_moderation_session(super_token) as api:
3806 user_state_id = api.GetModerationState(
3807 moderation_pb2.GetModerationStateReq(
3808 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER,
3809 object_id=surfer.id,
3810 )
3811 ).moderation_state.moderation_state_id
3813 res = api.SetUserContentVisibility(
3814 moderation_pb2.SetUserContentVisibilityReq(
3815 user_id=surfer.id,
3816 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3817 )
3818 )
3819 assert res.updated_count == 1
3821 with session_scope() as session:
3822 content_state = session.execute(
3823 select(ModerationState).where(ModerationState.id == content_state_id)
3824 ).scalar_one()
3825 assert content_state.visibility == ModerationVisibility.hidden
3827 user_state = session.execute(select(ModerationState).where(ModerationState.id == user_state_id)).scalar_one()
3828 assert user_state.visibility is None
3831# ============================================================================
3832# Tests for the moderation model metadata
3833# ============================================================================
3836def test_every_object_type_has_a_model():
3837 """A type in the enum with no model behind it is accepted everywhere and understood nowhere."""
3838 assert set(get_moderated_models()) == set(ModerationObjectType)
3841@pytest.mark.parametrize("object_type", list(ModerationObjectType))
3842def test_visibility_check_constraint_matches_the_models(db, object_type):
3843 """The check constraint spells out the types with their own visibility mechanism; this makes sure it agrees with the models."""
3844 has_own_visibility_mechanism = get_moderated_models()[object_type].has_own_visibility_mechanism
3846 def add_state(object_id: int, visibility: ModerationVisibility | None) -> None:
3847 with session_scope() as session:
3848 session.add(ModerationState(object_type=object_type, object_id=object_id, visibility=visibility))
3850 if has_own_visibility_mechanism:
3851 add_state(1, None)
3852 with pytest.raises(IntegrityError):
3853 add_state(2, ModerationVisibility.visible)
3854 else:
3855 add_state(1, ModerationVisibility.visible)
3856 with pytest.raises(IntegrityError):
3857 add_state(2, None)