Coverage for app/backend/src/tests/test_moderation.py: 100%
1638 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +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
60def create_test_host_request_with_moderation(surfer_token, host_user_id):
61 """Helper to create a host request and return its moderation state ID"""
62 today_plus_2 = (today() + timedelta(days=2)).isoformat()
63 today_plus_3 = (today() + timedelta(days=3)).isoformat()
65 with requests_session(surfer_token) as api:
66 hr_id = api.CreateHostRequest(
67 requests_pb2.CreateHostRequestReq(
68 host_user_id=host_user_id,
69 from_date=today_plus_2,
70 to_date=today_plus_3,
71 text=valid_request_text(),
72 )
73 ).host_request_id
75 backdate_conversations()
77 with session_scope() as session:
78 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
79 return hr.moderation_state_id
82# ============================================================================
83# Tests for moderation helper functions
84# ============================================================================
87def test_create_moderation(db):
88 """Test creating a moderation state with associated log entry"""
89 user, _ = generate_user()
91 with session_scope() as session:
92 # Create a moderation state
93 moderation_state = create_moderation(
94 session=session,
95 object_type=ModerationObjectType.host_request,
96 object_id=123,
97 creator_user_id=user.id,
98 )
100 assert moderation_state.object_type == ModerationObjectType.host_request
101 assert moderation_state.object_id == 123
102 assert moderation_state.visibility == ModerationVisibility.shadowed
104 # Check that log entry was created
105 log_entries = (
106 session.execute(select(ModerationLog).where(ModerationLog.moderation_state_id == moderation_state.id))
107 .scalars()
108 .all()
109 )
111 assert len(log_entries) == 1
112 assert log_entries[0].action == ModerationAction.create
113 assert log_entries[0].reason == "Object created."
114 assert log_entries[0].moderator_user_id == user.id
117def test_add_to_moderation_queue(db):
118 """Test adding content to moderation queue via API"""
119 super_user, super_token = generate_user(is_superuser=True)
120 user1, token1 = generate_user()
121 user2, _ = generate_user()
123 today_plus_2 = (today() + timedelta(days=2)).isoformat()
124 today_plus_3 = (today() + timedelta(days=3)).isoformat()
126 # Create a real host request (which automatically creates moderation state and adds to queue)
127 with requests_session(token1) as api:
128 host_request_id = api.CreateHostRequest(
129 requests_pb2.CreateHostRequestReq(
130 host_user_id=user2.id,
131 from_date=today_plus_2,
132 to_date=today_plus_3,
133 text=valid_request_text(),
134 )
135 ).host_request_id
137 # Get the moderation state ID
138 state_id = None
139 with session_scope() as session:
140 host_request = session.execute(
141 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
142 ).scalar_one()
143 state_id = host_request.moderation_state_id
145 # Add another item to moderation queue via API (the first one was created automatically)
146 with real_moderation_session(super_token) as api:
147 api.ModerateContent(
148 moderation_pb2.ModerateContentReq(
149 moderation_state_id=state_id,
150 action=moderation_pb2.MODERATION_ACTION_FLAG,
151 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
152 reason="Admin manually flagged for additional review",
153 )
154 )
156 with session_scope() as session:
157 flag = session.execute(
158 select(ModerationQueueItem)
159 .where(ModerationQueueItem.moderation_state_id == state_id)
160 .where(ModerationQueueItem.trigger == ModerationTrigger.user_flag)
161 ).scalar_one()
162 assert flag.reason == "Admin manually flagged for additional review"
163 assert flag.resolved_by_log_id is None
165 # The FLAG action is recorded in the log, pointing at the new queue item.
166 flag_log = session.execute(
167 select(ModerationLog)
168 .where(ModerationLog.moderation_state_id == state_id)
169 .where(ModerationLog.action == ModerationAction.flag)
170 ).scalar_one()
171 assert flag_log.queue_item_id == flag.id
174def test_flag_with_data(db):
175 """A flag can carry a JSON payload"""
176 super_user, super_token = generate_user(is_superuser=True)
177 user1, token1 = generate_user()
178 user2, _ = generate_user()
180 state_id = create_test_host_request_with_moderation(token1, user2.id)
182 with real_moderation_session(super_token) as api:
183 api.ModerateContent(
184 moderation_pb2.ModerateContentReq(
185 moderation_state_id=state_id,
186 action=moderation_pb2.MODERATION_ACTION_FLAG,
187 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG,
188 reason="Automod",
189 data=json.dumps({"classifier": "spam", "score": 0.97, "matches": ["buy now"]}),
190 )
191 )
193 res = api.GetModerationQueue(
194 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG])
195 )
196 assert len(res.queue_items) == 1
197 assert json.loads(res.queue_items[0].data) == {
198 "classifier": "spam",
199 "score": 0.97,
200 "matches": ["buy now"],
201 }
203 with session_scope() as session:
204 flag = session.execute(
205 select(ModerationQueueItem)
206 .where(ModerationQueueItem.moderation_state_id == state_id)
207 .where(ModerationQueueItem.trigger == ModerationTrigger.machine_flag)
208 ).scalar_one()
209 assert flag.data == {"classifier": "spam", "score": 0.97, "matches": ["buy now"]}
212def test_flag_without_data(db):
213 """A flag with no data serializes as an empty string"""
214 super_user, super_token = generate_user(is_superuser=True)
215 user1, token1 = generate_user()
216 user2, _ = generate_user()
218 state_id = create_test_host_request_with_moderation(token1, user2.id)
220 with real_moderation_session(super_token) as api:
221 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
222 assert len(res.queue_items) == 1
223 assert res.queue_items[0].data == ""
225 with session_scope() as session:
226 item = session.execute(
227 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
228 ).scalar_one()
229 assert item.data is None
232def test_flag_with_invalid_data(db):
233 """Flag data must be valid JSON"""
234 super_user, super_token = generate_user(is_superuser=True)
235 user1, token1 = generate_user()
236 user2, _ = generate_user()
238 state_id = create_test_host_request_with_moderation(token1, user2.id)
240 with real_moderation_session(super_token) as api:
241 with pytest.raises(grpc.RpcError) as e:
242 api.ModerateContent(
243 moderation_pb2.ModerateContentReq(
244 moderation_state_id=state_id,
245 action=moderation_pb2.MODERATION_ACTION_FLAG,
246 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG,
247 reason="Automod",
248 data="not json",
249 )
250 )
251 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
252 assert e.value.details() == "The moderation data must be valid JSON."
255def test_data_rejected_on_non_flag_action(db):
256 """Data has nowhere to go on actions that don't create a queue item"""
257 super_user, super_token = generate_user(is_superuser=True)
258 user1, token1 = generate_user()
259 user2, _ = generate_user()
261 state_id = create_test_host_request_with_moderation(token1, user2.id)
263 with real_moderation_session(super_token) as api:
264 with pytest.raises(grpc.RpcError) as e:
265 api.ModerateContent(
266 moderation_pb2.ModerateContentReq(
267 moderation_state_id=state_id,
268 action=moderation_pb2.MODERATION_ACTION_APPROVE,
269 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
270 reason="Looks fine",
271 data=json.dumps({"score": 0.1}),
272 )
273 )
274 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
275 assert e.value.details() == "Moderation data can only be set when flagging."
278def test_create_moderation_with_data(db):
279 """create_moderation can attach data to the initial review item"""
280 user, _ = generate_user()
282 with session_scope() as session:
283 moderation_state = create_moderation(
284 session=session,
285 object_type=ModerationObjectType.host_request,
286 object_id=123,
287 creator_user_id=user.id,
288 data={"classifier": "spam", "score": 0.42},
289 )
291 item = session.execute(
292 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == moderation_state.id)
293 ).scalar_one()
294 assert item.trigger == ModerationTrigger.initial_review
295 assert item.data == {"classifier": "spam", "score": 0.42}
298def test_moderate_content(db):
299 """Test moderating content via API"""
300 super_user, super_token = generate_user(is_superuser=True)
301 user, token = generate_user()
302 host, _ = generate_user()
304 today_plus_2 = (today() + timedelta(days=2)).isoformat()
305 today_plus_3 = (today() + timedelta(days=3)).isoformat()
307 # Create a real host request
308 state_id = None
309 with requests_session(token) as api:
310 hr_id = api.CreateHostRequest(
311 requests_pb2.CreateHostRequestReq(
312 host_user_id=host.id,
313 from_date=today_plus_2,
314 to_date=today_plus_3,
315 text=valid_request_text(),
316 )
317 ).host_request_id
319 with session_scope() as session:
320 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
321 state_id = hr.moderation_state_id
323 # Moderate the content via API
324 with real_moderation_session(super_token) as api:
325 res = api.ModerateContent(
326 moderation_pb2.ModerateContentReq(
327 moderation_state_id=state_id,
328 action=moderation_pb2.MODERATION_ACTION_APPROVE,
329 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
330 reason="Content looks good",
331 )
332 )
334 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
336 # Check that state was updated in database
337 with session_scope() as session:
338 updated_state = session.get_one(ModerationState, state_id)
339 assert updated_state.visibility == ModerationVisibility.visible
341 # Check that log entry was created
342 log_entries = (
343 session.execute(
344 select(ModerationLog)
345 .where(ModerationLog.moderation_state_id == state_id)
346 .order_by(ModerationLog.time.desc(), ModerationLog.id.desc())
347 )
348 .scalars()
349 .all()
350 )
352 assert len(log_entries) == 2 # CREATE + APPROVE
353 assert log_entries[0].action == ModerationAction.approve
354 assert log_entries[0].moderator_user_id == super_user.id
355 assert log_entries[0].reason == "Content looks good"
358def test_resolve_queue_item(db):
359 """Test resolving a moderation queue item via ModerateContent API"""
360 user1, token1 = generate_user()
361 user2, _ = generate_user()
362 moderator, moderator_token = generate_user(is_superuser=True)
364 today_plus_2 = (today() + timedelta(days=2)).isoformat()
365 today_plus_3 = (today() + timedelta(days=3)).isoformat()
367 # Create a host request using the API (which automatically creates moderation state)
368 with requests_session(token1) as api:
369 host_request_id = api.CreateHostRequest(
370 requests_pb2.CreateHostRequestReq(
371 host_user_id=user2.id,
372 from_date=today_plus_2,
373 to_date=today_plus_3,
374 text=valid_request_text(),
375 )
376 ).host_request_id
378 state_id = None
379 with session_scope() as session:
380 # Get the host request and its moderation state
381 host_request = session.execute(
382 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
383 ).scalar_one()
384 state_id = host_request.moderation_state_id
386 # The moderation state should already exist and be in the queue
387 queue_item = session.execute(
388 select(ModerationQueueItem)
389 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
390 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
391 ).scalar_one()
393 assert queue_item.resolved_by_log_id is None
395 # Approve content with clear_flags, which resolves the open queue item(s)
396 with real_moderation_session(moderator_token) as api:
397 api.ModerateContent(
398 moderation_pb2.ModerateContentReq(
399 moderation_state_id=state_id,
400 action=moderation_pb2.MODERATION_ACTION_APPROVE,
401 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
402 reason="Approved after review",
403 clear_flags=True,
404 )
405 )
407 # Check that queue item was resolved
408 with session_scope() as session:
409 queue_item = session.execute(
410 select(ModerationQueueItem)
411 .where(ModerationQueueItem.moderation_state_id == state_id)
412 .where(ModerationQueueItem.resolved_by_log_id.is_not(None))
413 ).scalar_one()
414 assert queue_item.resolved_by_log_id is not None
417def test_approve_content_via_api(db):
418 """Test approving content via ModerateContent API"""
419 user1, token1 = generate_user()
420 user2, _ = generate_user()
421 moderator, moderator_token = generate_user(is_superuser=True)
423 today_plus_2 = (today() + timedelta(days=2)).isoformat()
424 today_plus_3 = (today() + timedelta(days=3)).isoformat()
426 # Create a host request using the API (which automatically creates moderation state)
427 with requests_session(token1) as api:
428 host_request_id = api.CreateHostRequest(
429 requests_pb2.CreateHostRequestReq(
430 host_user_id=user2.id,
431 from_date=today_plus_2,
432 to_date=today_plus_3,
433 text=valid_request_text(),
434 )
435 ).host_request_id
437 state_id = None
438 with session_scope() as session:
439 # Get the host request and its moderation state
440 host_request = session.execute(
441 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
442 ).scalar_one()
443 state_id = host_request.moderation_state_id
445 # Approve via API
446 with real_moderation_session(moderator_token) as api:
447 api.ModerateContent(
448 moderation_pb2.ModerateContentReq(
449 moderation_state_id=state_id,
450 action=moderation_pb2.MODERATION_ACTION_APPROVE,
451 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
452 reason="Quick approval",
453 )
454 )
456 # Check that state was updated to VISIBLE
457 with session_scope() as session:
458 updated_state = session.get_one(ModerationState, state_id)
459 assert updated_state.visibility == ModerationVisibility.visible
461 # Check log entry
462 log_entry = session.execute(
463 select(ModerationLog)
464 .where(ModerationLog.moderation_state_id == state_id)
465 .where(ModerationLog.action == ModerationAction.approve)
466 ).scalar_one()
468 assert log_entry.moderator_user_id == moderator.id
469 assert log_entry.reason == "Quick approval"
472# ============================================================================
473# Tests for host request moderation integration
474# ============================================================================
477def test_create_host_request_creates_moderation_state(db):
478 """Test that creating a host request automatically creates a moderation state"""
479 user1, token1 = generate_user()
480 user2, token2 = generate_user()
482 today_plus_2 = (today() + timedelta(days=2)).isoformat()
483 today_plus_3 = (today() + timedelta(days=3)).isoformat()
485 with requests_session(token1) as api:
486 host_request_id = api.CreateHostRequest(
487 requests_pb2.CreateHostRequestReq(
488 host_user_id=user2.id,
489 from_date=today_plus_2,
490 to_date=today_plus_3,
491 text=valid_request_text(),
492 )
493 ).host_request_id
495 with session_scope() as session:
496 # Check that host request has a moderation state
497 host_request = session.execute(
498 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
499 ).scalar_one()
501 # Check moderation state properties
502 moderation_state = session.execute(
503 select(ModerationState).where(ModerationState.id == host_request.moderation_state_id)
504 ).scalar_one()
506 assert moderation_state.object_type == ModerationObjectType.host_request
507 assert moderation_state.object_id == host_request_id
508 assert moderation_state.visibility == ModerationVisibility.shadowed
510 # Check that it was added to moderation queue
511 queue_items = (
512 session.execute(
513 select(ModerationQueueItem)
514 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
515 .where(ModerationQueueItem.resolved_by_log_id == None)
516 )
517 .scalars()
518 .all()
519 )
521 assert len(queue_items) == 1
522 assert queue_items[0].trigger == ModerationTrigger.initial_review
523 # item_author_user_id is no longer stored in the model, it's dynamically retrieved
526def test_host_request_no_notification_before_approval(db, push_collector: PushCollector):
527 """Test that host requests don't send notifications until approved"""
528 user1, token1 = generate_user()
529 user2, token2 = generate_user()
531 today_plus_2 = (today() + timedelta(days=2)).isoformat()
532 today_plus_3 = (today() + timedelta(days=3)).isoformat()
534 with requests_session(token1) as api:
535 host_request_id = api.CreateHostRequest(
536 requests_pb2.CreateHostRequestReq(
537 host_user_id=user2.id,
538 from_date=today_plus_2,
539 to_date=today_plus_3,
540 text=valid_request_text(),
541 )
542 ).host_request_id
544 # Process all jobs (including the notification job)
545 process_jobs()
547 # No push notification should be sent yet (host requests are shadowed initially)
548 assert push_collector.count_for_user(user2.id) == 0
551def test_shadowed_notification_not_in_list_notifications(db):
552 """Test that notifications for shadowed content don't appear in ListNotifications API"""
553 user1, token1 = generate_user()
554 user2, token2 = generate_user()
556 today_plus_2 = (today() + timedelta(days=2)).isoformat()
557 today_plus_3 = (today() + timedelta(days=3)).isoformat()
559 # Create a host request (which creates a shadowed notification for the host)
560 with requests_session(token1) as api:
561 host_request_id = api.CreateHostRequest(
562 requests_pb2.CreateHostRequestReq(
563 host_user_id=user2.id,
564 from_date=today_plus_2,
565 to_date=today_plus_3,
566 text=valid_request_text(),
567 )
568 ).host_request_id
570 # Host (recipient) should NOT see the notification in ListNotifications - it's for shadowed content
571 with notifications_session(token2) as api:
572 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
573 # Should be empty - the host request is still shadowed
574 assert len(res.notifications) == 0
577def test_notification_visible_after_approval(db):
578 """Test that notifications appear in ListNotifications after content is approved"""
579 user1, token1 = generate_user()
580 user2, token2 = generate_user()
581 mod, mod_token = generate_user(is_superuser=True)
583 today_plus_2 = (today() + timedelta(days=2)).isoformat()
584 today_plus_3 = (today() + timedelta(days=3)).isoformat()
586 # Create a host request (which creates a shadowed notification for the host)
587 with requests_session(token1) as api:
588 host_request_id = api.CreateHostRequest(
589 requests_pb2.CreateHostRequestReq(
590 host_user_id=user2.id,
591 from_date=today_plus_2,
592 to_date=today_plus_3,
593 text=valid_request_text(),
594 )
595 ).host_request_id
597 # Host (recipient) should NOT see the notification initially
598 with notifications_session(token2) as api:
599 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
600 assert len(res.notifications) == 0
602 # Get the moderation state ID and approve
603 with session_scope() as session:
604 host_request = session.execute(
605 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
606 ).scalar_one()
607 state_id = host_request.moderation_state_id
609 with real_moderation_session(mod_token) as api:
610 api.ModerateContent(
611 moderation_pb2.ModerateContentReq(
612 moderation_state_id=state_id,
613 action=moderation_pb2.MODERATION_ACTION_APPROVE,
614 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
615 reason="Looks good",
616 )
617 )
619 # Now host SHOULD see the notification
620 with notifications_session(token2) as api:
621 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
622 assert len(res.notifications) == 1
623 assert res.notifications[0].topic == "host_request"
624 assert res.notifications[0].action == "create"
627def test_shadowed_host_request_visible_to_author_only(db):
628 """Test that SHADOWED host requests are visible only to the author (surfer), not the recipient (host)"""
629 user1, token1 = generate_user()
630 user2, token2 = generate_user()
632 today_plus_2 = (today() + timedelta(days=2)).isoformat()
633 today_plus_3 = (today() + timedelta(days=3)).isoformat()
635 with requests_session(token1) as api:
636 host_request_id = api.CreateHostRequest(
637 requests_pb2.CreateHostRequestReq(
638 host_user_id=user2.id,
639 from_date=today_plus_2,
640 to_date=today_plus_3,
641 text=valid_request_text(),
642 )
643 ).host_request_id
645 # Surfer (author) can see it with GetHostRequest
646 with requests_session(token1) as api:
647 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
648 assert res.host_request_id == host_request_id
649 assert res.latest_message.text.text == valid_request_text()
651 # Host (recipient) CANNOT see it with GetHostRequest - it's shadowed
652 with requests_session(token2) as api:
653 with pytest.raises(grpc.RpcError) as e:
654 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
655 assert e.value.code() == grpc.StatusCode.NOT_FOUND
658def test_unlisted_host_request_not_in_lists(db):
659 """Test that SHADOWED host requests are visible to author but not to recipient"""
660 user1, token1 = generate_user()
661 user2, token2 = generate_user()
663 today_plus_2 = (today() + timedelta(days=2)).isoformat()
664 today_plus_3 = (today() + timedelta(days=3)).isoformat()
666 with requests_session(token1) as api:
667 host_request_id = api.CreateHostRequest(
668 requests_pb2.CreateHostRequestReq(
669 host_user_id=user2.id,
670 from_date=today_plus_2,
671 to_date=today_plus_3,
672 text=valid_request_text(),
673 )
674 ).host_request_id
676 # Surfer (author) should see it in their sent list even though it's SHADOWED
677 with requests_session(token1) as api:
678 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
679 assert len(res.host_requests) == 1
681 # Host should NOT see it in their received list (still SHADOWED from them)
682 with requests_session(token2) as api:
683 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
684 assert len(res.host_requests) == 0
687def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector):
688 """Test that approved host requests appear in lists and send notifications"""
689 user1, token1 = generate_user()
690 user2, token2 = generate_user()
691 mod, mod_token = generate_user(is_superuser=True)
693 today_plus_2 = (today() + timedelta(days=2)).isoformat()
694 today_plus_3 = (today() + timedelta(days=3)).isoformat()
696 with requests_session(token1) as api:
697 host_request_id = api.CreateHostRequest(
698 requests_pb2.CreateHostRequestReq(
699 host_user_id=user2.id,
700 from_date=today_plus_2,
701 to_date=today_plus_3,
702 text=valid_request_text(),
703 )
704 ).host_request_id
706 # Process the initial notification job - should be deferred (no notification sent)
707 process_jobs()
708 assert push_collector.count_for_user(user2.id) == 0
710 # Get the moderation state ID
711 state_id = None
712 with session_scope() as session:
713 host_request = session.execute(
714 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
715 ).scalar_one()
716 state_id = host_request.moderation_state_id
718 # Approve the host request via API
719 with real_moderation_session(mod_token) as api:
720 api.ModerateContent(
721 moderation_pb2.ModerateContentReq(
722 moderation_state_id=state_id,
723 action=moderation_pb2.MODERATION_ACTION_APPROVE,
724 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
725 reason="Looks good",
726 )
727 )
729 # Process the re-queued notification job - should now send notification
730 process_jobs()
732 # Now surfer SHOULD see it in their sent list
733 with requests_session(token1) as api:
734 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
735 assert len(res.host_requests) == 1
736 assert res.host_requests[0].host_request_id == host_request_id
738 # Host SHOULD see it in their received list
739 with requests_session(token2) as api:
740 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
741 assert len(res.host_requests) == 1
742 assert res.host_requests[0].host_request_id == host_request_id
744 # After approval, the host should have received a push notification
745 assert push_collector.pop_for_user(user2.id, last=True).topic_action == "host_request:create"
748def test_hidden_host_request_invisible_to_all(db):
749 """Test that HIDDEN host requests are invisible to everyone except moderators"""
750 user1, token1 = generate_user()
751 user2, token2 = generate_user()
752 user3, token3 = generate_user() # Third party
753 moderator, moderator_token = generate_user(is_superuser=True)
755 today_plus_2 = (today() + timedelta(days=2)).isoformat()
756 today_plus_3 = (today() + timedelta(days=3)).isoformat()
758 with requests_session(token1) as api:
759 host_request_id = api.CreateHostRequest(
760 requests_pb2.CreateHostRequestReq(
761 host_user_id=user2.id,
762 from_date=today_plus_2,
763 to_date=today_plus_3,
764 text=valid_request_text(),
765 )
766 ).host_request_id
768 # Get the moderation state ID
769 state_id = None
770 with session_scope() as session:
771 host_request = session.execute(
772 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
773 ).scalar_one()
774 state_id = host_request.moderation_state_id
776 # Hide the host request via API (e.g., spam/abuse)
777 with real_moderation_session(moderator_token) as api:
778 api.ModerateContent(
779 moderation_pb2.ModerateContentReq(
780 moderation_state_id=state_id,
781 action=moderation_pb2.MODERATION_ACTION_HIDE,
782 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
783 reason="Spam content",
784 )
785 )
787 # Surfer can't see it with GetHostRequest
788 with requests_session(token1) as api:
789 with pytest.raises(grpc.RpcError) as e:
790 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
791 assert e.value.code() == grpc.StatusCode.NOT_FOUND
793 # Host can't see it with GetHostRequest
794 with requests_session(token2) as api:
795 with pytest.raises(grpc.RpcError) as e:
796 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
797 assert e.value.code() == grpc.StatusCode.NOT_FOUND
799 # Third party definitely can't see it
800 with requests_session(token3) as api:
801 with pytest.raises(grpc.RpcError) as e:
802 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
803 assert e.value.code() == grpc.StatusCode.NOT_FOUND
805 # Not in any lists
806 with requests_session(token1) as api:
807 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
808 assert len(res.host_requests) == 0
810 with requests_session(token2) as api:
811 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
812 assert len(res.host_requests) == 0
815def test_multiple_host_requests_listing_visibility(db):
816 """Test that ListHostRequests correctly filters based on moderation state"""
817 user1, token1 = generate_user()
818 user2, token2 = generate_user()
819 moderator, moderator_token = generate_user(is_superuser=True)
821 today_plus_2 = (today() + timedelta(days=2)).isoformat()
822 today_plus_3 = (today() + timedelta(days=3)).isoformat()
824 # Create 3 host requests
825 host_request_ids = []
826 state_ids = []
827 with requests_session(token1) as api:
828 for i in range(3):
829 hr_id = api.CreateHostRequest(
830 requests_pb2.CreateHostRequestReq(
831 host_user_id=user2.id,
832 from_date=today_plus_2,
833 to_date=today_plus_3,
834 text=valid_request_text(f"Test request {i + 1}"),
835 )
836 ).host_request_id
837 host_request_ids.append(hr_id)
838 backdate_conversations()
840 # Get state IDs
841 with session_scope() as session:
842 for hr_id in host_request_ids:
843 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
844 state_ids.append(host_request.moderation_state_id)
846 # Approve the first one via API
847 with real_moderation_session(moderator_token) as api:
848 api.ModerateContent(
849 moderation_pb2.ModerateContentReq(
850 moderation_state_id=state_ids[0],
851 action=moderation_pb2.MODERATION_ACTION_APPROVE,
852 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
853 reason="Approved",
854 )
855 )
857 # Hide the third one via API
858 with real_moderation_session(moderator_token) as api:
859 api.ModerateContent(
860 moderation_pb2.ModerateContentReq(
861 moderation_state_id=state_ids[2],
862 action=moderation_pb2.MODERATION_ACTION_HIDE,
863 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
864 reason="Spam",
865 )
866 )
868 # Surfer should see the approved one and the shadowed one (author can see their SHADOWED content)
869 with requests_session(token1) as api:
870 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
871 assert len(res.host_requests) == 2
872 visible_ids = {hr.host_request_id for hr in res.host_requests}
873 assert visible_ids == {host_request_ids[0], host_request_ids[1]}
875 # Host should see only the approved one in received list
876 with requests_session(token2) as api:
877 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
878 assert len(res.host_requests) == 1
879 assert res.host_requests[0].host_request_id == host_request_ids[0]
882def test_moderation_log_tracking(db):
883 """Test that moderation actions are properly logged via API"""
884 user, user_token = generate_user()
885 host, _ = generate_user()
886 moderator1, moderator1_token = generate_user(is_superuser=True)
887 moderator2, moderator2_token = generate_user(is_superuser=True)
889 # Create a real host request
890 state_id = create_test_host_request_with_moderation(user_token, host.id)
892 # Perform several moderation actions via API
893 with real_moderation_session(moderator1_token) as api:
894 api.ModerateContent(
895 moderation_pb2.ModerateContentReq(
896 moderation_state_id=state_id,
897 action=moderation_pb2.MODERATION_ACTION_APPROVE,
898 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
899 reason="Looks good initially",
900 )
901 )
903 with real_moderation_session(moderator2_token) as api:
904 api.ModerateContent(
905 moderation_pb2.ModerateContentReq(
906 moderation_state_id=state_id,
907 action=moderation_pb2.MODERATION_ACTION_FLAG,
908 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
909 reason="Wait, this needs another look",
910 )
911 )
912 # Shadow it back
913 api.ModerateContent(
914 moderation_pb2.ModerateContentReq(
915 moderation_state_id=state_id,
916 action=moderation_pb2.MODERATION_ACTION_HIDE,
917 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
918 reason="Wait, this needs another look",
919 )
920 )
922 with real_moderation_session(moderator1_token) as api:
923 api.ModerateContent(
924 moderation_pb2.ModerateContentReq(
925 moderation_state_id=state_id,
926 action=moderation_pb2.MODERATION_ACTION_HIDE,
927 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
928 reason="Actually it's spam",
929 )
930 )
932 # Check all log entries
933 with session_scope() as session:
934 log_entries = (
935 session.execute(
936 select(ModerationLog)
937 .where(ModerationLog.moderation_state_id == state_id)
938 .order_by(ModerationLog.time.asc())
939 )
940 .scalars()
941 .all()
942 )
944 # CREATE + APPROVE + HIDE + HIDE (shadowing back counts as HIDE action)
945 assert len(log_entries) >= 3
947 assert log_entries[0].action == ModerationAction.create
948 assert log_entries[0].moderator_user_id == user.id
949 assert log_entries[0].reason == "Object created."
951 assert log_entries[1].action == ModerationAction.approve
952 assert log_entries[1].moderator_user_id == moderator1.id
953 assert log_entries[1].reason == "Looks good initially"
955 # The last action should be hiding
956 assert log_entries[-1].action == ModerationAction.hide
957 assert log_entries[-1].moderator_user_id == moderator1.id
958 assert log_entries[-1].reason == "Actually it's spam"
961def test_moderation_queue_workflow(db):
962 """Test the full moderation queue workflow via API"""
963 user1, token1 = generate_user()
964 user2, _ = generate_user()
965 moderator, moderator_token = generate_user(is_superuser=True)
967 today_plus_2 = (today() + timedelta(days=2)).isoformat()
968 today_plus_3 = (today() + timedelta(days=3)).isoformat()
970 # Create a host request using the API (which automatically creates moderation state and adds to queue)
971 with requests_session(token1) as api:
972 host_request_id = api.CreateHostRequest(
973 requests_pb2.CreateHostRequestReq(
974 host_user_id=user2.id,
975 from_date=today_plus_2,
976 to_date=today_plus_3,
977 text=valid_request_text(),
978 )
979 ).host_request_id
981 state_id = None
982 queue_item_id = None
983 with session_scope() as session:
984 # Get the host request and its moderation state
985 host_request = session.execute(
986 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
987 ).scalar_one()
988 state_id = host_request.moderation_state_id
990 # The queue item should already exist (created automatically)
991 queue_item = session.execute(
992 select(ModerationQueueItem)
993 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
994 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
995 ).scalar_one()
996 queue_item_id = queue_item.id
998 # Verify it's in the queue
999 unresolved_items = (
1000 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
1001 .scalars()
1002 .all()
1003 )
1005 assert len(unresolved_items) >= 1
1006 assert queue_item.id in [item.id for item in unresolved_items]
1008 # Moderator reviews and approves via API, clearing the open queue item
1009 with real_moderation_session(moderator_token) as api:
1010 api.ModerateContent(
1011 moderation_pb2.ModerateContentReq(
1012 moderation_state_id=state_id,
1013 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1014 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1015 reason="Content approved",
1016 clear_flags=True,
1017 )
1018 )
1020 # Verify queue item was resolved
1021 with session_scope() as session:
1022 # Verify it's no longer in unresolved queue
1023 unresolved_items = (
1024 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
1025 .scalars()
1026 .all()
1027 )
1029 assert queue_item_id not in [item.id for item in unresolved_items]
1031 # Verify the queue item was linked to a log entry
1032 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
1033 assert queue_item.resolved_by_log_id is not None
1036# ============================================================================
1037# Moderation API Tests (testing the gRPC servicer)
1038# ============================================================================
1041def test_GetModerationQueue_empty(db):
1042 """Test getting an empty moderation queue"""
1043 super_user, super_token = generate_user(is_superuser=True)
1045 with real_moderation_session(super_token) as api:
1046 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1047 assert len(res.queue_items) == 0
1048 assert res.next_page_token == ""
1051def test_GetModerationQueue_with_items(db):
1052 """Test getting moderation queue with items via API"""
1053 super_user, super_token = generate_user(is_superuser=True)
1054 normal_user, user_token = generate_user()
1055 host, _ = generate_user()
1057 # Create some host requests (which automatically adds them to moderation queue)
1058 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1059 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1061 with real_moderation_session(super_token) as api:
1062 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1063 assert len(res.queue_items) == 2
1064 assert res.queue_items[0].is_resolved == False
1065 assert res.queue_items[1].is_resolved == False
1068def test_GetModerationQueue_filter_by_trigger(db):
1069 """Test filtering moderation queue by trigger type via API"""
1070 super_user, super_token = generate_user(is_superuser=True)
1071 normal_user, user_token = generate_user()
1072 host, _ = generate_user()
1074 # Create host requests (which automatically adds them to moderation queue with INITIAL_REVIEW)
1075 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1076 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1078 # Add USER_FLAG trigger to second item via API
1079 with real_moderation_session(super_token) as api:
1080 api.ModerateContent(
1081 moderation_pb2.ModerateContentReq(
1082 moderation_state_id=state2_id,
1083 action=moderation_pb2.MODERATION_ACTION_FLAG,
1084 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
1085 reason="Reported by user",
1086 )
1087 )
1089 # Filter by INITIAL_REVIEW (should get first item and maybe both depending on how queue works)
1090 with real_moderation_session(super_token) as api:
1091 res = api.GetModerationQueue(
1092 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW])
1093 )
1094 assert len(res.queue_items) == 2 # Both have INITIAL_REVIEW triggers
1095 assert all(item.trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW for item in res.queue_items)
1097 # Filter by USER_FLAG (should get second item only)
1098 with real_moderation_session(super_token) as api:
1099 res = api.GetModerationQueue(
1100 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_USER_FLAG])
1101 )
1102 assert len(res.queue_items) == 1
1103 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG
1106def test_GetModerationQueue_filter_created_before(db):
1107 """Test filtering moderation queue by created_before timestamp"""
1108 super_user, super_token = generate_user(is_superuser=True)
1109 normal_user, user_token = generate_user()
1110 host, _ = generate_user()
1112 # Create host requests
1113 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1114 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1116 # Backdate the first queue item
1117 with session_scope() as session:
1118 queue_item1 = session.execute(
1119 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1120 ).scalar_one()
1121 # Set it to 2 hours ago
1122 queue_item1.time_created = now() - timedelta(hours=2)
1124 # The second item remains at current time
1126 # Filter to items created before 1 hour ago (should only get the first item)
1127 cutoff_time = now() - timedelta(hours=1)
1128 with real_moderation_session(super_token) as api:
1129 res = api.GetModerationQueue(
1130 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(cutoff_time))
1131 )
1132 assert len(res.queue_items) == 1
1133 assert res.queue_items[0].moderation_state_id == state1_id
1135 # Filter to items created before now (should get both)
1136 with real_moderation_session(super_token) as api:
1137 res = api.GetModerationQueue(
1138 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(now() + timedelta(seconds=10)))
1139 )
1140 assert len(res.queue_items) == 2
1142 # Filter to items created before 3 hours ago (should get none)
1143 old_cutoff = now() - timedelta(hours=3)
1144 with real_moderation_session(super_token) as api:
1145 res = api.GetModerationQueue(
1146 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(old_cutoff))
1147 )
1148 assert len(res.queue_items) == 0
1151def test_GetModerationQueue_filter_created_after(db):
1152 """Test filtering moderation queue by created_after timestamp"""
1153 super_user, super_token = generate_user(is_superuser=True)
1154 normal_user, user_token = generate_user()
1155 host, _ = generate_user()
1157 # Create host requests
1158 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1159 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1161 # Backdate the first queue item to 2 hours ago
1162 with session_scope() as session:
1163 queue_item1 = session.execute(
1164 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1165 ).scalar_one()
1166 queue_item1.time_created = now() - timedelta(hours=2)
1168 # The second item remains at current time
1170 # Filter to items created after 1 hour ago (should only get the second item)
1171 cutoff_time = now() - timedelta(hours=1)
1172 with real_moderation_session(super_token) as api:
1173 res = api.GetModerationQueue(
1174 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(cutoff_time))
1175 )
1176 assert len(res.queue_items) == 1
1177 assert res.queue_items[0].moderation_state_id == state2_id
1179 # Filter to items created after 3 hours ago (should get both)
1180 old_cutoff = now() - timedelta(hours=3)
1181 with real_moderation_session(super_token) as api:
1182 res = api.GetModerationQueue(
1183 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(old_cutoff))
1184 )
1185 assert len(res.queue_items) == 2
1187 # Filter to items created after now (should get none)
1188 with real_moderation_session(super_token) as api:
1189 res = api.GetModerationQueue(
1190 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(now() + timedelta(seconds=10)))
1191 )
1192 assert len(res.queue_items) == 0
1195def test_GetModerationQueue_filter_created_before_and_after(db):
1196 """Test filtering moderation queue by both created_before and created_after timestamps"""
1197 super_user, super_token = generate_user(is_superuser=True)
1198 normal_user, user_token = generate_user()
1199 host, _ = generate_user()
1201 # Create 3 host requests
1202 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1203 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1204 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1206 # Set different times: state1 = 3 hours ago, state2 = 1.5 hours ago, state3 = now
1207 with session_scope() as session:
1208 queue_item1 = session.execute(
1209 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1210 ).scalar_one()
1211 queue_item1.time_created = now() - timedelta(hours=3)
1213 queue_item2 = session.execute(
1214 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1215 ).scalar_one()
1216 queue_item2.time_created = now() - timedelta(hours=1, minutes=30)
1218 # Filter to items between 2 hours ago and 1 hour ago (should only get state2)
1219 after_cutoff = now() - timedelta(hours=2)
1220 before_cutoff = now() - timedelta(hours=1)
1221 with real_moderation_session(super_token) as api:
1222 res = api.GetModerationQueue(
1223 moderation_pb2.GetModerationQueueReq(
1224 created_after=Timestamp_from_datetime(after_cutoff),
1225 created_before=Timestamp_from_datetime(before_cutoff),
1226 )
1227 )
1228 assert len(res.queue_items) == 1
1229 assert res.queue_items[0].moderation_state_id == state2_id
1231 # Filter to items between 4 hours ago and 2.5 hours ago (should only get state1)
1232 after_cutoff = now() - timedelta(hours=4)
1233 before_cutoff = now() - timedelta(hours=2, minutes=30)
1234 with real_moderation_session(super_token) as api:
1235 res = api.GetModerationQueue(
1236 moderation_pb2.GetModerationQueueReq(
1237 created_after=Timestamp_from_datetime(after_cutoff),
1238 created_before=Timestamp_from_datetime(before_cutoff),
1239 )
1240 )
1241 assert len(res.queue_items) == 1
1242 assert res.queue_items[0].moderation_state_id == state1_id
1245def test_GetModerationQueue_filter_unresolved(db):
1246 """Test filtering moderation queue for unresolved items only via API"""
1247 super_user, super_token = generate_user(is_superuser=True)
1248 normal_user, user_token = generate_user()
1249 host, _ = generate_user()
1251 # Create 2 host requests
1252 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1253 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1255 # Resolve the first one via API (approve with clear_flags resolves the queue item)
1256 with real_moderation_session(super_token) as api:
1257 api.ModerateContent(
1258 moderation_pb2.ModerateContentReq(
1259 moderation_state_id=state1_id,
1260 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1261 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1262 reason="Approved",
1263 clear_flags=True,
1264 )
1265 )
1267 # Get all items
1268 with real_moderation_session(super_token) as api:
1269 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1270 assert len(res.queue_items) == 2
1272 # Get only unresolved items
1273 with real_moderation_session(super_token) as api:
1274 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1275 assert len(res.queue_items) == 1
1276 assert res.queue_items[0].is_resolved == False
1279def test_GetModerationQueue_filter_by_author(db):
1280 """Test filtering moderation queue by item_author_user_id"""
1281 super_user, super_token = generate_user(is_superuser=True)
1282 user1, token1 = generate_user()
1283 user2, token2 = generate_user()
1284 host_user, _ = generate_user()
1286 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1287 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1289 # Create 2 host requests by user1
1290 with requests_session(token1) as api:
1291 hr1_id = api.CreateHostRequest(
1292 requests_pb2.CreateHostRequestReq(
1293 host_user_id=host_user.id,
1294 from_date=today_plus_2,
1295 to_date=today_plus_3,
1296 text=valid_request_text(),
1297 )
1298 ).host_request_id
1299 backdate_conversations()
1301 hr2_id = api.CreateHostRequest(
1302 requests_pb2.CreateHostRequestReq(
1303 host_user_id=host_user.id,
1304 from_date=today_plus_2,
1305 to_date=today_plus_3,
1306 text=valid_request_text(),
1307 )
1308 ).host_request_id
1310 # Create 1 host request by user2
1311 with requests_session(token2) as api:
1312 hr3_id = api.CreateHostRequest(
1313 requests_pb2.CreateHostRequestReq(
1314 host_user_id=host_user.id,
1315 from_date=today_plus_2,
1316 to_date=today_plus_3,
1317 text=valid_request_text(),
1318 )
1319 ).host_request_id
1321 # Get moderation state IDs
1322 state1_id, state2_id, state3_id = None, None, None
1323 with session_scope() as session:
1324 hr1 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr1_id)).scalar_one()
1325 hr2 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr2_id)).scalar_one()
1326 hr3 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr3_id)).scalar_one()
1327 state1_id = hr1.moderation_state_id
1328 state2_id = hr2.moderation_state_id
1329 state3_id = hr3.moderation_state_id
1331 # Get all items (should be 3)
1332 with real_moderation_session(super_token) as api:
1333 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1334 assert len(res.queue_items) == 3
1336 # Filter by user1 (should get 2)
1337 with real_moderation_session(super_token) as api:
1338 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user1.id))
1339 assert len(res.queue_items) == 2
1340 assert all(item.moderation_state.author.user_id == user1.id for item in res.queue_items)
1342 # Filter by user2 (should get 1)
1343 with real_moderation_session(super_token) as api:
1344 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user2.id))
1345 assert len(res.queue_items) == 1
1346 assert res.queue_items[0].moderation_state.author.user_id == user2.id
1347 assert res.queue_items[0].moderation_state_id == state3_id
1349 # Filter by non-existent user (should get 0)
1350 with real_moderation_session(super_token) as api:
1351 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=999999))
1352 assert len(res.queue_items) == 0
1355def test_GetModerationQueue_ordering(db):
1356 """Test ordering moderation queue by oldest/newest first"""
1357 super_user, super_token = generate_user(is_superuser=True)
1358 normal_user, user_token = generate_user()
1359 host, _ = generate_user()
1361 # Create 3 host requests
1362 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1363 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1364 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1366 # Set different times: state1 = 3 hours ago, state2 = 2 hours ago, state3 = 1 hour ago
1367 with session_scope() as session:
1368 queue_item1 = session.execute(
1369 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1370 ).scalar_one()
1371 queue_item1.time_created = now() - timedelta(hours=3)
1373 queue_item2 = session.execute(
1374 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1375 ).scalar_one()
1376 queue_item2.time_created = now() - timedelta(hours=2)
1378 queue_item3 = session.execute(
1379 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state3_id)
1380 ).scalar_one()
1381 queue_item3.time_created = now() - timedelta(hours=1)
1383 # Default order (oldest first)
1384 with real_moderation_session(super_token) as api:
1385 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1386 assert len(res.queue_items) == 3
1387 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1388 assert res.queue_items[1].moderation_state_id == state2_id
1389 assert res.queue_items[2].moderation_state_id == state3_id # newest
1391 # Explicit oldest first
1392 with real_moderation_session(super_token) as api:
1393 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=False))
1394 assert len(res.queue_items) == 3
1395 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1396 assert res.queue_items[2].moderation_state_id == state3_id # newest
1398 # Newest first
1399 with real_moderation_session(super_token) as api:
1400 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=True))
1401 assert len(res.queue_items) == 3
1402 assert res.queue_items[0].moderation_state_id == state3_id # newest
1403 assert res.queue_items[1].moderation_state_id == state2_id
1404 assert res.queue_items[2].moderation_state_id == state1_id # oldest
1407def test_GetModerationQueue_pagination_newest_first(db):
1408 """Test pagination with newest_first=True returns different items on each page"""
1409 super_user, super_token = generate_user(is_superuser=True)
1410 normal_user, normal_token = generate_user()
1411 host_user, _ = generate_user()
1413 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1414 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1416 # Create 5 host requests
1417 hr_ids = []
1418 with requests_session(normal_token) as api:
1419 for i in range(5):
1420 hr_id = api.CreateHostRequest(
1421 requests_pb2.CreateHostRequestReq(
1422 host_user_id=host_user.id,
1423 from_date=today_plus_2,
1424 to_date=today_plus_3,
1425 text=valid_request_text(),
1426 )
1427 ).host_request_id
1428 hr_ids.append(hr_id)
1429 backdate_conversations()
1431 # Get moderation state IDs
1432 state_ids = []
1433 with session_scope() as session:
1434 for hr_id in hr_ids:
1435 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
1436 state_ids.append(hr.moderation_state_id)
1438 # Set different times so ordering is deterministic
1439 with session_scope() as session:
1440 for i, state_id in enumerate(state_ids):
1441 queue_item = session.execute(
1442 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
1443 ).scalar_one()
1444 queue_item.time_created = now() - timedelta(hours=5 - i) # oldest first in list
1446 # Get first page (2 items) with newest_first=True, filtered to our user's items
1447 with real_moderation_session(super_token) as api:
1448 res1 = api.GetModerationQueue(
1449 moderation_pb2.GetModerationQueueReq(page_size=2, newest_first=True, item_author_user_id=normal_user.id)
1450 )
1451 assert len(res1.queue_items) == 2
1452 # Should get newest items: state_ids[4], state_ids[3]
1453 assert res1.queue_items[0].moderation_state_id == state_ids[4]
1454 assert res1.queue_items[1].moderation_state_id == state_ids[3]
1455 assert res1.next_page_token # should have more pages
1457 # Get second page using the token
1458 res2 = api.GetModerationQueue(
1459 moderation_pb2.GetModerationQueueReq(
1460 page_size=2, newest_first=True, page_token=res1.next_page_token, item_author_user_id=normal_user.id
1461 )
1462 )
1463 assert len(res2.queue_items) == 2
1464 # Should get next newest items: state_ids[2], state_ids[1]
1465 assert res2.queue_items[0].moderation_state_id == state_ids[2]
1466 assert res2.queue_items[1].moderation_state_id == state_ids[1]
1468 # Pages should not overlap
1469 page1_ids = {item.moderation_state_id for item in res1.queue_items}
1470 page2_ids = {item.moderation_state_id for item in res2.queue_items}
1471 assert page1_ids.isdisjoint(page2_ids), "Pages should not have overlapping items"
1474def test_GetModerationQueue_pagination_ignores_time_created(db):
1475 """Backfilled queue items were given ids out of a separate range, so time_created and id disagree."""
1476 super_user, super_token = generate_user(is_superuser=True)
1477 normal_user, normal_token = generate_user()
1478 host, _ = generate_user()
1480 first_state_id = create_test_host_request_with_moderation(normal_token, host.id)
1481 create_test_host_request_with_moderation(normal_token, host.id)
1482 create_test_host_request_with_moderation(normal_token, host.id)
1484 with session_scope() as session:
1485 item = session.execute(
1486 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == first_state_id)
1487 ).scalar_one()
1488 item.time_created = now() + timedelta(days=1)
1490 paged_ids = []
1491 page_token = ""
1492 with real_moderation_session(super_token) as api:
1493 while True:
1494 res = api.GetModerationQueue(
1495 moderation_pb2.GetModerationQueueReq(page_size=2, page_token=page_token, newest_first=True)
1496 )
1497 paged_ids += [item.queue_item_id for item in res.queue_items]
1498 page_token = res.next_page_token
1499 if not page_token:
1500 break
1502 with session_scope() as session:
1503 expected = list(
1504 session.execute(select(ModerationQueueItem.id).order_by(ModerationQueueItem.id.desc())).scalars().all()
1505 )
1507 assert paged_ids == expected
1510def test_GetModerationLog(db):
1511 """Test getting moderation log for a state via API"""
1512 super_user, super_token = generate_user(is_superuser=True)
1513 moderator, moderator_token = generate_user(is_superuser=True)
1514 normal_user, user_token = generate_user()
1515 host, _ = generate_user()
1517 # Create a real host request
1518 state_id = create_test_host_request_with_moderation(user_token, host.id)
1520 # Perform a moderation action via API
1521 with real_moderation_session(moderator_token) as api:
1522 api.ModerateContent(
1523 moderation_pb2.ModerateContentReq(
1524 moderation_state_id=state_id,
1525 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1526 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1527 reason="Looks good",
1528 )
1529 )
1531 with real_moderation_session(super_token) as api:
1532 res = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
1533 assert len(res.log_entries) == 2 # CREATE + APPROVE
1534 assert res.moderation_state.moderation_state_id == state_id
1535 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1536 # Log entries are in reverse chronological order
1537 assert res.log_entries[0].action == moderation_pb2.MODERATION_ACTION_APPROVE
1538 assert res.log_entries[0].moderator_user_id == moderator.id
1539 assert res.log_entries[0].reason == "Looks good"
1540 assert res.log_entries[1].action == moderation_pb2.MODERATION_ACTION_CREATE
1541 assert res.log_entries[1].moderator_user_id == normal_user.id
1544def test_GetModerationLog_not_found(db):
1545 """Test getting moderation log for non-existent state"""
1546 super_user, super_token = generate_user(is_superuser=True)
1548 with real_moderation_session(super_token) as api:
1549 with pytest.raises(grpc.RpcError) as e:
1550 api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=999999))
1551 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1552 assert e.value.details() == "Moderation state not found."
1555def test_GetModerationState(db):
1556 """Test getting moderation state by object type and ID"""
1557 super_user, super_token = generate_user(is_superuser=True)
1558 user1, token1 = generate_user()
1559 user2, _ = generate_user()
1561 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1562 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1564 with requests_session(token1) as api:
1565 host_request_id = api.CreateHostRequest(
1566 requests_pb2.CreateHostRequestReq(
1567 host_user_id=user2.id,
1568 from_date=today_plus_2,
1569 to_date=today_plus_3,
1570 text=valid_request_text(),
1571 )
1572 ).host_request_id
1574 with real_moderation_session(super_token) as api:
1575 res = api.GetModerationState(
1576 moderation_pb2.GetModerationStateReq(
1577 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1578 object_id=host_request_id,
1579 )
1580 )
1581 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST
1582 assert res.moderation_state.object_id == host_request_id
1583 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1584 assert res.moderation_state.moderation_state_id > 0
1587def test_GetModerationState_not_found(db):
1588 """Test getting moderation state for non-existent object"""
1589 super_user, super_token = generate_user(is_superuser=True)
1591 with real_moderation_session(super_token) as api:
1592 with pytest.raises(grpc.RpcError) as e:
1593 api.GetModerationState(
1594 moderation_pb2.GetModerationStateReq(
1595 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1596 object_id=999999,
1597 )
1598 )
1599 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1600 assert e.value.details() == "Moderation state not found."
1603def test_GetModerationState_unspecified_type(db):
1604 """Test getting moderation state with unspecified object type"""
1605 super_user, super_token = generate_user(is_superuser=True)
1607 with real_moderation_session(super_token) as api:
1608 with pytest.raises(grpc.RpcError) as e:
1609 api.GetModerationState(
1610 moderation_pb2.GetModerationStateReq(
1611 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED,
1612 object_id=123,
1613 )
1614 )
1615 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1616 assert e.value.details() == "Object type must be specified."
1619def test_ModerateContent_approve(db):
1620 """Test approving content via unified moderation API"""
1621 super_user, super_token = generate_user(is_superuser=True)
1622 user1, token1 = generate_user()
1623 user2, _ = generate_user()
1625 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1626 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1628 # Create a host request using the API (which automatically creates moderation state)
1629 with requests_session(token1) as api:
1630 host_request_id = api.CreateHostRequest(
1631 requests_pb2.CreateHostRequestReq(
1632 host_user_id=user2.id,
1633 from_date=today_plus_2,
1634 to_date=today_plus_3,
1635 text=valid_request_text(),
1636 )
1637 ).host_request_id
1639 # Get the moderation state ID
1640 state_id = None
1641 with session_scope() as session:
1642 host_request = session.execute(
1643 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1644 ).scalar_one()
1645 state_id = host_request.moderation_state_id
1647 with real_moderation_session(super_token) as api:
1648 res = api.ModerateContent(
1649 moderation_pb2.ModerateContentReq(
1650 moderation_state_id=state_id,
1651 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1652 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1653 reason="Approved by admin",
1654 )
1655 )
1656 assert res.moderation_state.moderation_state_id == state_id
1657 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1659 # Verify state was updated in database
1660 with session_scope() as session:
1661 state = session.get_one(ModerationState, state_id)
1662 assert state.visibility == ModerationVisibility.visible
1665def test_ModerateContent_not_found(db):
1666 """Test moderating non-existent content"""
1667 super_user, super_token = generate_user(is_superuser=True)
1669 with real_moderation_session(super_token) as api:
1670 with pytest.raises(grpc.RpcError) as e:
1671 api.ModerateContent(
1672 moderation_pb2.ModerateContentReq(
1673 moderation_state_id=999999,
1674 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1675 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1676 reason="Test",
1677 )
1678 )
1679 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1680 assert e.value.details() == "Moderation state not found."
1683def test_ModerateContent_hide(db):
1684 """Test hiding content via unified moderation API"""
1685 super_user, super_token = generate_user(is_superuser=True)
1686 normal_user, user_token = generate_user()
1687 host, _ = generate_user()
1689 # Create a real host request
1690 state_id = create_test_host_request_with_moderation(user_token, host.id)
1692 with real_moderation_session(super_token) as api:
1693 res = api.ModerateContent(
1694 moderation_pb2.ModerateContentReq(
1695 moderation_state_id=state_id,
1696 action=moderation_pb2.MODERATION_ACTION_HIDE,
1697 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1698 reason="Spam content",
1699 )
1700 )
1701 assert res.moderation_state.moderation_state_id == state_id
1702 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_HIDDEN
1704 # Verify state was updated in database
1705 with session_scope() as session:
1706 state = session.get_one(ModerationState, state_id)
1707 assert state.visibility == ModerationVisibility.hidden
1710def test_ModerateContent_shadow(db):
1711 """Test shadowing content via unified moderation API"""
1712 super_user, super_token = generate_user(is_superuser=True)
1713 normal_user, user_token = generate_user()
1714 host, _ = generate_user()
1716 # Create a real host request
1717 state_id = create_test_host_request_with_moderation(user_token, host.id)
1719 with real_moderation_session(super_token) as api:
1720 res = api.ModerateContent(
1721 moderation_pb2.ModerateContentReq(
1722 moderation_state_id=state_id,
1723 action=moderation_pb2.MODERATION_ACTION_HIDE,
1724 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
1725 reason="Needs further review",
1726 )
1727 )
1728 assert res.moderation_state.moderation_state_id == state_id
1729 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1731 # Verify state was updated in database
1732 with session_scope() as session:
1733 state = session.get_one(ModerationState, state_id)
1734 assert state.visibility == ModerationVisibility.shadowed
1737def test_ModerateContent_flag(db):
1738 """Test opening a flag via the FLAG action"""
1739 super_user, super_token = generate_user(is_superuser=True)
1740 user1, token1 = generate_user()
1741 user2, _ = generate_user()
1743 # Create a host request
1744 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1745 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1747 with requests_session(token1) as api:
1748 host_request_id = api.CreateHostRequest(
1749 requests_pb2.CreateHostRequestReq(
1750 host_user_id=user2.id,
1751 from_date=today_plus_2,
1752 to_date=today_plus_3,
1753 text=valid_request_text(),
1754 )
1755 ).host_request_id
1757 # Get the moderation state ID
1758 with session_scope() as session:
1759 host_request = session.execute(
1760 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1761 ).scalar_one()
1762 state_id = host_request.moderation_state_id
1764 with real_moderation_session(super_token) as api:
1765 api.ModerateContent(
1766 moderation_pb2.ModerateContentReq(
1767 moderation_state_id=state_id,
1768 action=moderation_pb2.MODERATION_ACTION_FLAG,
1769 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
1770 reason="Admin flagged for additional review",
1771 priority=5,
1772 )
1773 )
1775 # Verify queue item was created in database with the given priority, plus a log row referencing it
1776 with session_scope() as session:
1777 queue_item = session.execute(
1778 select(ModerationQueueItem)
1779 .where(ModerationQueueItem.moderation_state_id == state_id)
1780 .where(ModerationQueueItem.trigger == ModerationTrigger.moderator_review)
1781 ).scalar_one()
1782 assert queue_item.resolved_by_log_id is None
1783 assert queue_item.priority == 5
1785 flag_log = session.execute(
1786 select(ModerationLog)
1787 .where(ModerationLog.moderation_state_id == state_id)
1788 .where(ModerationLog.action == ModerationAction.flag)
1789 ).scalar_one()
1790 assert flag_log.queue_item_id == queue_item.id
1793def test_ModerateContent_flag_requires_trigger(db):
1794 """FLAG without a trigger is rejected."""
1795 super_user, super_token = generate_user(is_superuser=True)
1796 user1, token1 = generate_user()
1797 user2, _ = generate_user()
1798 state_id = create_test_host_request_with_moderation(token1, user2.id)
1800 with real_moderation_session(super_token) as api:
1801 with pytest.raises(grpc.RpcError) as e:
1802 api.ModerateContent(
1803 moderation_pb2.ModerateContentReq(
1804 moderation_state_id=state_id,
1805 action=moderation_pb2.MODERATION_ACTION_FLAG,
1806 reason="no trigger",
1807 )
1808 )
1809 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1812def _open_queue_item_id(state_id):
1813 with session_scope() as session:
1814 return (
1815 session.execute(
1816 select(ModerationQueueItem)
1817 .where(ModerationQueueItem.moderation_state_id == state_id)
1818 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1819 )
1820 .scalars()
1821 .one()
1822 .id
1823 )
1826def test_ModerateContent_set_priority(db):
1827 """SET_PRIORITY changes a flag's priority and logs it, without touching visibility."""
1828 moderator, mod_token = generate_user(is_superuser=True)
1829 user1, token1 = generate_user()
1830 user2, _ = generate_user()
1831 state_id = create_test_host_request_with_moderation(token1, user2.id)
1832 queue_item_id = _open_queue_item_id(state_id)
1834 with real_moderation_session(mod_token) as api:
1835 api.ModerateContent(
1836 moderation_pb2.ModerateContentReq(
1837 moderation_state_id=state_id,
1838 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY,
1839 queue_item_id=queue_item_id,
1840 priority=10,
1841 reason="bump",
1842 )
1843 )
1845 with session_scope() as session:
1846 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
1847 assert queue_item.priority == 10
1848 assert queue_item.resolved_by_log_id is None # not resolved
1849 # State stays shadowed (visibility untouched)
1850 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.shadowed
1852 log = session.execute(
1853 select(ModerationLog)
1854 .where(ModerationLog.moderation_state_id == state_id)
1855 .where(ModerationLog.action == ModerationAction.set_priority)
1856 ).scalar_one()
1857 assert log.new_priority == 10
1858 assert log.queue_item_id == queue_item_id
1860 # GetModerationQueue surfaces the new priority
1861 with real_moderation_session(mod_token) as api:
1862 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1863 item = next(i for i in res.queue_items if i.queue_item_id == queue_item_id)
1864 assert item.priority == 10
1867def test_ModerateContent_unflag(db):
1868 """UNFLAG resolves a single named flag and does not change visibility."""
1869 moderator, mod_token = generate_user(is_superuser=True)
1870 user1, token1 = generate_user()
1871 user2, _ = generate_user()
1872 state_id = create_test_host_request_with_moderation(token1, user2.id)
1873 queue_item_id = _open_queue_item_id(state_id)
1875 with real_moderation_session(mod_token) as api:
1876 api.ModerateContent(
1877 moderation_pb2.ModerateContentReq(
1878 moderation_state_id=state_id,
1879 action=moderation_pb2.MODERATION_ACTION_UNFLAG,
1880 queue_item_id=queue_item_id,
1881 reason="dismissed",
1882 )
1883 )
1885 with session_scope() as session:
1886 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
1887 assert queue_item.resolved_by_log_id is not None
1888 # Visibility unchanged (still shadowed from creation)
1889 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.shadowed
1890 log = session.get_one(ModerationLog, queue_item.resolved_by_log_id)
1891 assert log.action == ModerationAction.unflag
1892 assert log.queue_item_id == queue_item_id
1895def test_ModerateContent_unflag_requires_queue_item(db):
1896 """UNFLAG without a queue_item_id is rejected."""
1897 moderator, mod_token = generate_user(is_superuser=True)
1898 user1, token1 = generate_user()
1899 user2, _ = generate_user()
1900 state_id = create_test_host_request_with_moderation(token1, user2.id)
1902 with real_moderation_session(mod_token) as api:
1903 with pytest.raises(grpc.RpcError) as e:
1904 api.ModerateContent(
1905 moderation_pb2.ModerateContentReq(
1906 moderation_state_id=state_id,
1907 action=moderation_pb2.MODERATION_ACTION_UNFLAG,
1908 reason="no target",
1909 )
1910 )
1911 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1914def test_ModerateContent_approve_without_clear_flags_leaves_flag_open(db):
1915 """APPROVE without clear_flags changes visibility but leaves open flags in the queue."""
1916 moderator, mod_token = generate_user(is_superuser=True)
1917 user1, token1 = generate_user()
1918 user2, _ = generate_user()
1919 state_id = create_test_host_request_with_moderation(token1, user2.id)
1920 queue_item_id = _open_queue_item_id(state_id)
1922 with real_moderation_session(mod_token) as api:
1923 api.ModerateContent(
1924 moderation_pb2.ModerateContentReq(
1925 moderation_state_id=state_id,
1926 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1927 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1928 reason="visible but still under review",
1929 )
1930 )
1932 with session_scope() as session:
1933 assert session.get_one(ModerationState, state_id).visibility == ModerationVisibility.visible
1934 assert session.get_one(ModerationQueueItem, queue_item_id).resolved_by_log_id is None
1937def test_ModerateContent_flag_supersede(db):
1938 """FLAG with supersede_queue_item_id resolves the named flag as the new one opens."""
1939 moderator, mod_token = generate_user(is_superuser=True)
1940 user1, token1 = generate_user()
1941 user2, _ = generate_user()
1942 state_id = create_test_host_request_with_moderation(token1, user2.id)
1943 initial_item_id = _open_queue_item_id(state_id)
1945 with real_moderation_session(mod_token) as api:
1946 api.ModerateContent(
1947 moderation_pb2.ModerateContentReq(
1948 moderation_state_id=state_id,
1949 action=moderation_pb2.MODERATION_ACTION_FLAG,
1950 trigger=moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG,
1951 priority=3,
1952 supersede_queue_item_id=initial_item_id,
1953 reason="machine re-flag",
1954 )
1955 )
1957 with session_scope() as session:
1958 # Old initial_review flag is resolved
1959 assert session.get_one(ModerationQueueItem, initial_item_id).resolved_by_log_id is not None
1960 # Exactly one open flag remains: the new machine_flag at priority 3
1961 open_items = (
1962 session.execute(
1963 select(ModerationQueueItem)
1964 .where(ModerationQueueItem.moderation_state_id == state_id)
1965 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1966 )
1967 .scalars()
1968 .all()
1969 )
1970 assert len(open_items) == 1
1971 assert open_items[0].trigger == ModerationTrigger.machine_flag
1972 assert open_items[0].priority == 3
1975def test_GetModerationQueue_filter_by_priority(db):
1976 """priority_min / priority_max filter the queue by priority range."""
1977 moderator, mod_token = generate_user(is_superuser=True)
1978 user1, token1 = generate_user()
1979 user2, _ = generate_user()
1980 state_id = create_test_host_request_with_moderation(token1, user2.id)
1981 queue_item_id = _open_queue_item_id(state_id)
1983 with real_moderation_session(mod_token) as api:
1984 api.ModerateContent(
1985 moderation_pb2.ModerateContentReq(
1986 moderation_state_id=state_id,
1987 action=moderation_pb2.MODERATION_ACTION_SET_PRIORITY,
1988 queue_item_id=queue_item_id,
1989 priority=7,
1990 reason="raise",
1991 )
1992 )
1994 # priority_min excludes it below the threshold, includes at/above
1995 assert len(api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_min=8)).queue_items) == 0
1996 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_min=7))
1997 assert [i.queue_item_id for i in res.queue_items] == [queue_item_id]
1999 # priority_max excludes it above the threshold
2000 assert len(api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_max=6)).queue_items) == 0
2001 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(priority_max=7))
2002 assert [i.queue_item_id for i in res.queue_items] == [queue_item_id]
2005# ============================================================================
2006# Tests for group chat moderation
2007# ============================================================================
2010def test_group_chat_created_with_moderation_state(db):
2011 """Test that group chats are created with moderation state"""
2012 user1, token1 = generate_user()
2013 user2, _ = generate_user()
2014 make_friends(user1, user2)
2016 with conversations_session(token1) as api:
2017 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
2018 group_chat_id = res.group_chat_id
2020 # Verify moderation state was created
2021 with session_scope() as session:
2022 group_chat = session.execute(select(GroupChat).where(GroupChat.conversation_id == group_chat_id)).scalar_one()
2024 assert group_chat.moderation_state.object_type == ModerationObjectType.group_chat
2025 assert group_chat.moderation_state.object_id == group_chat_id
2026 # Group chats start as SHADOWED
2027 assert group_chat.moderation_state.visibility == ModerationVisibility.shadowed
2029 # A moderation queue item should have been created
2030 queue_item = (
2031 session.execute(
2032 select(ModerationQueueItem).where(
2033 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id
2034 )
2035 )
2036 .scalars()
2037 .first()
2038 )
2039 assert queue_item is not None
2040 assert queue_item.trigger == ModerationTrigger.initial_review
2043def test_group_chat_GetModerationState(db):
2044 """Test GetModerationState API for group chats"""
2045 user1, token1 = generate_user()
2046 user2, _ = generate_user()
2047 moderator, mod_token = generate_user(is_superuser=True)
2048 make_friends(user1, user2)
2050 with conversations_session(token1) as api:
2051 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
2052 group_chat_id = res.group_chat_id
2054 # Moderator can look up the moderation state
2055 with real_moderation_session(mod_token) as api:
2056 res = api.GetModerationState(
2057 moderation_pb2.GetModerationStateReq(
2058 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
2059 object_id=group_chat_id,
2060 )
2061 )
2062 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT
2063 assert res.moderation_state.object_id == group_chat_id
2064 # Starts as SHADOWED
2065 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2068def test_group_chat_moderation_hide(db):
2069 """Test that a moderator can hide a group chat and participants can no longer see it"""
2070 user1, token1 = generate_user()
2071 user2, token2 = generate_user()
2072 moderator, mod_token = generate_user(is_superuser=True)
2073 make_friends(user1, user2)
2075 with conversations_session(token1) as api:
2076 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
2077 group_chat_id = res.group_chat_id
2078 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
2080 # First approve the group chat so both users can see it
2081 with real_moderation_session(mod_token) as api:
2082 state_res = api.GetModerationState(
2083 moderation_pb2.GetModerationStateReq(
2084 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
2085 object_id=group_chat_id,
2086 )
2087 )
2088 api.ModerateContent(
2089 moderation_pb2.ModerateContentReq(
2090 moderation_state_id=state_res.moderation_state.moderation_state_id,
2091 action=moderation_pb2.MODERATION_ACTION_APPROVE,
2092 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2093 reason="Approved",
2094 )
2095 )
2097 # Both users can see the chat now
2098 with conversations_session(token1) as api:
2099 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2100 assert len(res.group_chats) == 1
2102 with conversations_session(token2) as api:
2103 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2104 assert len(res.group_chats) == 1
2106 # Moderator hides the group chat
2107 with real_moderation_session(mod_token) as api:
2108 state_res = api.GetModerationState(
2109 moderation_pb2.GetModerationStateReq(
2110 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
2111 object_id=group_chat_id,
2112 )
2113 )
2114 api.ModerateContent(
2115 moderation_pb2.ModerateContentReq(
2116 moderation_state_id=state_res.moderation_state.moderation_state_id,
2117 action=moderation_pb2.MODERATION_ACTION_HIDE,
2118 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
2119 reason="Inappropriate content",
2120 )
2121 )
2123 # Neither user can see the chat now
2124 with conversations_session(token1) as api:
2125 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2126 assert len(res.group_chats) == 0
2128 with conversations_session(token2) as api:
2129 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2130 assert len(res.group_chats) == 0
2132 # Trying to get messages returns empty (chat is hidden so no messages visible)
2133 with conversations_session(token1) as api:
2134 res = api.GetGroupChatMessages(conversations_pb2.GetGroupChatMessagesReq(group_chat_id=group_chat_id))
2135 assert len(res.messages) == 0
2138def test_group_chat_moderation_shadow(db):
2139 """Test that shadowing a group chat hides it from non-creator participants"""
2140 user1, token1 = generate_user() # Creator
2141 user2, token2 = generate_user() # Participant
2142 moderator, mod_token = generate_user(is_superuser=True)
2143 make_friends(user1, user2)
2145 with conversations_session(token1) as api:
2146 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
2147 group_chat_id = res.group_chat_id
2148 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
2150 # Moderator shadows the group chat
2151 with real_moderation_session(mod_token) as api:
2152 state_res = api.GetModerationState(
2153 moderation_pb2.GetModerationStateReq(
2154 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
2155 object_id=group_chat_id,
2156 )
2157 )
2158 api.ModerateContent(
2159 moderation_pb2.ModerateContentReq(
2160 moderation_state_id=state_res.moderation_state.moderation_state_id,
2161 action=moderation_pb2.MODERATION_ACTION_HIDE,
2162 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2163 reason="Needs review",
2164 )
2165 )
2167 # Creator can see SHADOWED content in list operations
2168 with conversations_session(token1) as api:
2169 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2170 assert len(res.group_chats) == 1
2171 assert res.group_chats[0].group_chat_id == group_chat_id
2173 # But non-creator participant cannot see it in lists
2174 with conversations_session(token2) as api:
2175 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
2176 assert len(res.group_chats) == 0
2178 # Creator can also access it directly via GetGroupChat
2179 with conversations_session(token1) as api:
2180 res = api.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id))
2181 assert res.group_chat_id == group_chat_id
2184# ============================================================================
2185# Tests for auto-approval background job
2186# ============================================================================
2189def test_auto_approve_moderation_queue_disabled_when_zero(db, email_collector: EmailCollector):
2190 """Test that auto-approval is disabled when deadline is 0"""
2191 moderator, mod_token = generate_user(is_superuser=True)
2192 user1, token1 = generate_user()
2193 user2, token2 = generate_user()
2195 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2196 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2198 # Create a host request
2199 with requests_session(token1) as api:
2200 host_request_id = api.CreateHostRequest(
2201 requests_pb2.CreateHostRequestReq(
2202 host_user_id=user2.id,
2203 from_date=today_plus_2,
2204 to_date=today_plus_3,
2205 text=valid_request_text(),
2206 )
2207 ).host_request_id
2209 # No email should have been sent (request is shadowed)
2210 assert email_collector.count_for_recipient(user2.email) == 0
2212 # Ensure deadline is 0 (disabled)
2213 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 0
2215 # Run the job
2216 auto_approve_moderation_queue(empty_pb2.Empty())
2218 # Surfer (author) can see the request via API
2219 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2220 assert res.host_request_id == host_request_id
2222 # Author can see their SHADOWED request in their sent list
2223 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
2224 assert len(res.host_requests) == 1
2225 assert res.host_requests[0].host_request_id == host_request_id
2227 # Host cannot see the request (it's shadowed from them)
2228 with requests_session(token2) as api:
2229 with pytest.raises(grpc.RpcError) as e:
2230 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2231 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2233 # Host doesn't see it in their received list either
2234 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2235 assert len(res.host_requests) == 0
2237 # Moderator can still see the item in the moderation queue
2238 with real_moderation_session(mod_token) as api:
2239 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2240 assert len(res.queue_items) == 1
2241 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW
2243 # Moderator can check the state is still SHADOWED
2244 state_res = api.GetModerationState(
2245 moderation_pb2.GetModerationStateReq(
2246 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2247 object_id=host_request_id,
2248 )
2249 )
2250 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2253def test_auto_approve_moderation_queue_approves_old_items(
2254 db, email_collector: EmailCollector, push_collector: PushCollector
2255):
2256 """Test that auto-approval approves items older than the deadline"""
2257 moderator, mod_token = generate_user(is_superuser=True)
2258 user1, token1 = generate_user()
2259 user2, token2 = generate_user()
2261 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2262 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2264 # Create a host request
2265 with requests_session(token1) as api:
2266 host_request_id = api.CreateHostRequest(
2267 requests_pb2.CreateHostRequestReq(
2268 host_user_id=user2.id,
2269 from_date=today_plus_2,
2270 to_date=today_plus_3,
2271 text=valid_request_text("Test request for auto-approval"),
2272 )
2273 ).host_request_id
2275 # No email sent initially (shadowed)
2276 assert email_collector.count_for_recipient(user2.email) == 0
2278 # Host cannot see the request yet
2279 with requests_session(token2) as api:
2280 with pytest.raises(grpc.RpcError) as e:
2281 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2282 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2284 # Make the queue item appear old by backdating its time_created
2285 with session_scope() as session:
2286 host_request = session.execute(
2287 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
2288 ).scalar_one()
2289 queue_item = session.execute(
2290 select(ModerationQueueItem)
2291 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
2292 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2293 ).scalar_one()
2294 # Backdate the queue item by 2 minutes
2295 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=2)
2297 # Set deadline to 60 seconds (items older than 60 seconds will be auto-approved)
2298 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2299 config.MODERATION_BOT_USER_ID = moderator.id
2301 # Run the job
2302 auto_approve_moderation_queue(empty_pb2.Empty())
2304 # Now host can see the request via API
2305 with requests_session(token2) as api:
2306 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2307 assert res.host_request_id == host_request_id
2308 assert res.latest_message.text.text == valid_request_text("Test request for auto-approval")
2310 # Host sees it in their received list
2311 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2312 assert len(res.host_requests) == 1
2313 assert res.host_requests[0].host_request_id == host_request_id
2315 # Surfer sees it in their sent list
2316 with requests_session(token1) as api:
2317 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
2318 assert len(res.host_requests) == 1
2319 assert res.host_requests[0].host_request_id == host_request_id
2321 with real_moderation_session(mod_token) as api:
2322 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2323 assert len(res.queue_items) == 1
2324 flag_item = res.queue_items[0]
2325 assert flag_item.trigger == moderation_pb2.MODERATION_TRIGGER_MACHINE_FLAG
2326 assert flag_item.priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY
2328 state_res = api.GetModerationState(
2329 moderation_pb2.GetModerationStateReq(
2330 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2331 object_id=host_request_id,
2332 )
2333 )
2334 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
2336 log_res = api.GetModerationLog(
2337 moderation_pb2.GetModerationLogReq(moderation_state_id=state_res.moderation_state.moderation_state_id)
2338 )
2339 approve_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_APPROVE]
2340 assert len(approve_entries) == 1
2341 assert "Auto-approved" in approve_entries[0].reason
2342 assert "60 seconds" in approve_entries[0].reason
2343 assert approve_entries[0].moderator_user_id == moderator.id
2345 flag_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_FLAG]
2346 assert len(flag_entries) == 1
2347 assert flag_entries[0].moderator_user_id == moderator.id
2350def test_auto_approve_does_not_approve_recent_items(db, email_collector: EmailCollector):
2351 """Test that auto-approval does not approve items that are newer than the deadline"""
2352 moderator, mod_token = generate_user(is_superuser=True)
2353 user1, token1 = generate_user()
2354 user2, token2 = generate_user()
2356 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2357 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2359 # Create a host request
2360 with requests_session(token1) as api:
2361 host_request_id = api.CreateHostRequest(
2362 requests_pb2.CreateHostRequestReq(
2363 host_user_id=user2.id,
2364 from_date=today_plus_2,
2365 to_date=today_plus_3,
2366 text=valid_request_text(),
2367 )
2368 ).host_request_id
2370 # No email sent (shadowed)
2371 assert email_collector.count_for_recipient(user2.email) == 0
2373 # Set deadline to 1 hour (items older than 1 hour will be auto-approved)
2374 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 3600
2375 config.MODERATION_BOT_USER_ID = moderator.id
2377 # Run the job - the item was just created, so it shouldn't be approved
2378 auto_approve_moderation_queue(empty_pb2.Empty())
2380 # Still no email sent
2381 assert email_collector.count_for_recipient(user2.email) == 0
2383 # Host still cannot see the request
2384 with requests_session(token2) as api:
2385 with pytest.raises(grpc.RpcError) as e:
2386 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2387 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2389 # Not in host's received list
2390 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
2391 assert len(res.host_requests) == 0
2393 # Moderator sees it still in queue unresolved
2394 with real_moderation_session(mod_token) as api:
2395 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2396 assert len(res.queue_items) == 1
2398 # State is still SHADOWED
2399 state_res = api.GetModerationState(
2400 moderation_pb2.GetModerationStateReq(
2401 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2402 object_id=host_request_id,
2403 )
2404 )
2405 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2408def test_auto_approve_does_not_approve_already_approved(db):
2409 """Test that auto-approval does not re-approve already visible content"""
2410 moderator, mod_token = generate_user(is_superuser=True)
2411 user1, token1 = generate_user()
2412 user2, token2 = generate_user()
2414 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2415 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2417 # Create a host request
2418 with requests_session(token1) as api:
2419 host_request_id = api.CreateHostRequest(
2420 requests_pb2.CreateHostRequestReq(
2421 host_user_id=user2.id,
2422 from_date=today_plus_2,
2423 to_date=today_plus_3,
2424 text=valid_request_text(),
2425 )
2426 ).host_request_id
2428 # Moderator approves it manually
2429 with real_moderation_session(mod_token) as api:
2430 state_res = api.GetModerationState(
2431 moderation_pb2.GetModerationStateReq(
2432 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2433 object_id=host_request_id,
2434 )
2435 )
2436 state_id = state_res.moderation_state.moderation_state_id
2438 api.ModerateContent(
2439 moderation_pb2.ModerateContentReq(
2440 moderation_state_id=state_id,
2441 action=moderation_pb2.MODERATION_ACTION_APPROVE,
2442 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2443 reason="Approved by moderator",
2444 clear_flags=True,
2445 )
2446 )
2448 # Host can now see it
2449 with requests_session(token2) as api:
2450 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2451 assert res.host_request_id == host_request_id
2453 # Get log count before auto-approval
2454 with real_moderation_session(mod_token) as api:
2455 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2456 log_count_before = len(log_res_before.log_entries)
2458 # Set deadline to 1 second
2459 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1
2460 config.MODERATION_BOT_USER_ID = moderator.id
2462 # Run the job
2463 auto_approve_moderation_queue(empty_pb2.Empty())
2465 # No new log entries should be created (already approved, queue item resolved)
2466 with real_moderation_session(mod_token) as api:
2467 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2468 assert len(log_res_after.log_entries) == log_count_before
2470 # Queue should be empty (item was resolved when moderator approved)
2471 queue_res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2472 assert len(queue_res.queue_items) == 0
2475def test_auto_approve_does_not_approve_moderator_shadowed_items(db):
2476 """Test that auto-approval does not approve items that were explicitly shadowed by a moderator"""
2477 moderator, mod_token = generate_user(is_superuser=True)
2478 user1, token1 = generate_user()
2479 user2, token2 = generate_user()
2481 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2482 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2484 # Create a host request
2485 with requests_session(token1) as api:
2486 host_request_id = api.CreateHostRequest(
2487 requests_pb2.CreateHostRequestReq(
2488 host_user_id=user2.id,
2489 from_date=today_plus_2,
2490 to_date=today_plus_3,
2491 text=valid_request_text(),
2492 )
2493 ).host_request_id
2495 # Moderator explicitly shadows the content (keeping it shadowed but resolving the queue item)
2496 with real_moderation_session(mod_token) as api:
2497 state_res = api.GetModerationState(
2498 moderation_pb2.GetModerationStateReq(
2499 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2500 object_id=host_request_id,
2501 )
2502 )
2503 state_id = state_res.moderation_state.moderation_state_id
2505 # Set to SHADOWED explicitly with clear_flags - this resolves the INITIAL_REVIEW queue item
2506 api.ModerateContent(
2507 moderation_pb2.ModerateContentReq(
2508 moderation_state_id=state_id,
2509 action=moderation_pb2.MODERATION_ACTION_HIDE,
2510 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2511 reason="Keeping shadowed for review",
2512 clear_flags=True,
2513 )
2514 )
2516 # Backdate to ensure it would be old enough for auto-approval
2517 with session_scope() as session:
2518 queue_item = session.execute(
2519 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
2520 ).scalar_one()
2521 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10)
2523 # Set deadline to 1 second
2524 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 1
2525 config.MODERATION_BOT_USER_ID = moderator.id
2527 # Get log count before
2528 with real_moderation_session(mod_token) as api:
2529 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2530 log_count_before = len(log_res_before.log_entries)
2532 # Run the job
2533 auto_approve_moderation_queue(empty_pb2.Empty())
2535 # No new log entries - the queue item was resolved when moderator shadowed it
2536 with real_moderation_session(mod_token) as api:
2537 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2538 assert len(log_res_after.log_entries) == log_count_before
2540 # State should still be SHADOWED (not auto-approved to VISIBLE)
2541 state_res = api.GetModerationState(
2542 moderation_pb2.GetModerationStateReq(
2543 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2544 object_id=host_request_id,
2545 )
2546 )
2547 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2549 # Host still cannot see the request
2550 with requests_session(token2) as api:
2551 with pytest.raises(grpc.RpcError) as e:
2552 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2553 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2556def test_auto_approve_skips_shadowed_user_authored_items(db):
2557 """Auto-approval must not promote content authored by a currently-shadowed user."""
2558 moderator, mod_token = generate_user(is_superuser=True)
2559 surfer, surfer_token = generate_user()
2560 host, host_token = generate_user()
2562 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2563 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2565 with requests_session(surfer_token) as api:
2566 host_request_id = api.CreateHostRequest(
2567 requests_pb2.CreateHostRequestReq(
2568 host_user_id=host.id,
2569 from_date=today_plus_2,
2570 to_date=today_plus_3,
2571 text=valid_request_text(),
2572 )
2573 ).host_request_id
2575 # Shadow the surfer after the host request was created
2576 with session_scope() as session:
2577 session.execute(select(User).where(User.id == surfer.id)).scalar_one().shadowed_at = now()
2579 # Backdate the queue item to make it eligible for auto-approval
2580 with session_scope() as session:
2581 host_request = session.execute(
2582 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
2583 ).scalar_one()
2584 queue_item = session.execute(
2585 select(ModerationQueueItem)
2586 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
2587 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2588 ).scalar_one()
2589 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10)
2591 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2592 config.MODERATION_BOT_USER_ID = moderator.id
2594 auto_approve_moderation_queue(empty_pb2.Empty())
2596 # State should remain SHADOWED — the auto-approve job must skip shadowed-user content
2597 with real_moderation_session(mod_token) as api:
2598 state_res = api.GetModerationState(
2599 moderation_pb2.GetModerationStateReq(
2600 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2601 object_id=host_request_id,
2602 )
2603 )
2604 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2606 # Host still cannot see the request
2607 with requests_session(host_token) as api:
2608 with pytest.raises(grpc.RpcError) as e:
2609 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2610 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2612 # But the (shadowed) author can still see their own request
2613 with requests_session(surfer_token) as api:
2614 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2615 assert res.host_request_id == host_request_id
2618def test_auto_approve_preserves_other_open_flags(db):
2619 """Auto-approval supersedes only the INITIAL_REVIEW item; other open flags survive and a high-prio flag is added."""
2620 moderator, mod_token = generate_user(is_superuser=True)
2621 surfer, surfer_token = generate_user()
2622 host, _ = generate_user()
2624 state_id = create_test_host_request_with_moderation(surfer_token, host.id)
2626 with real_moderation_session(mod_token) as api:
2627 api.ModerateContent(
2628 moderation_pb2.ModerateContentReq(
2629 moderation_state_id=state_id,
2630 action=moderation_pb2.MODERATION_ACTION_FLAG,
2631 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
2632 priority=5,
2633 reason="user reported this",
2634 )
2635 )
2637 with session_scope() as session:
2638 initial_item = session.execute(
2639 select(ModerationQueueItem)
2640 .where(ModerationQueueItem.moderation_state_id == state_id)
2641 .where(ModerationQueueItem.trigger == ModerationTrigger.initial_review)
2642 ).scalar_one()
2643 initial_item_id = initial_item.id
2644 initial_item.time_created = datetime.now(initial_item.time_created.tzinfo) - timedelta(minutes=10)
2646 config.MODERATION_AUTO_APPROVE_DEADLINE_SECONDS = 60
2647 config.MODERATION_BOT_USER_ID = moderator.id
2649 auto_approve_moderation_queue(empty_pb2.Empty())
2651 with session_scope() as session:
2652 assert (
2653 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.id == initial_item_id))
2654 .scalar_one()
2655 .resolved_by_log_id
2656 is not None
2657 )
2658 assert (
2659 session.execute(select(ModerationState).where(ModerationState.id == state_id)).scalar_one().visibility
2660 == ModerationVisibility.visible
2661 )
2663 open_items = (
2664 session.execute(
2665 select(ModerationQueueItem)
2666 .where(ModerationQueueItem.moderation_state_id == state_id)
2667 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
2668 )
2669 .scalars()
2670 .all()
2671 )
2672 open_by_trigger = {item.trigger: item for item in open_items}
2673 assert open_by_trigger.keys() == {ModerationTrigger.user_flag, ModerationTrigger.machine_flag}
2674 assert open_by_trigger[ModerationTrigger.user_flag].priority == 5
2675 assert open_by_trigger[ModerationTrigger.machine_flag].priority == MODERATION_AUTO_APPROVE_FLAG_PRIORITY
2678# ============================================================================
2679# Notification Suppression Tests
2680# ============================================================================
2683def test_host_request_message_notifications_suppressed_before_approval(
2684 db, email_collector: EmailCollector, push_collector: PushCollector, moderator
2685):
2686 """
2687 Test that notifications are NOT sent for messages in host requests
2688 that haven't been approved yet.
2689 """
2690 host, host_token = generate_user(complete_profile=True)
2691 surfer, surfer_token = generate_user(complete_profile=True)
2693 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2694 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2696 # Create host request (it starts in SHADOWED state)
2697 with requests_session(surfer_token) as api:
2698 hr_id = api.CreateHostRequest(
2699 requests_pb2.CreateHostRequestReq(
2700 host_user_id=host.id,
2701 from_date=today_plus_2,
2702 to_date=today_plus_3,
2703 text=valid_request_text("Initial request message"),
2704 )
2705 ).host_request_id
2707 # No notifications should have been sent to the host (request is SHADOWED)
2708 assert email_collector.count_for_recipient(host.email) == 0
2709 assert push_collector.count_for_user(host.id) == 0
2711 # Send additional messages BEFORE approval - should NOT generate notifications
2712 with requests_session(surfer_token) as api:
2713 api.SendHostRequestMessage(
2714 requests_pb2.SendHostRequestMessageReq(
2715 host_request_id=hr_id,
2716 text="Follow-up message 1",
2717 )
2718 )
2719 api.SendHostRequestMessage(
2720 requests_pb2.SendHostRequestMessageReq(
2721 host_request_id=hr_id,
2722 text="Follow-up message 2",
2723 )
2724 )
2726 # Host should STILL have no notifications (messages sent while SHADOWED)
2727 assert email_collector.count_for_recipient(host.email) == 0
2728 assert push_collector.count_for_user(host.id) == 0
2730 # Now approve the request
2731 moderator.approve_host_request(hr_id)
2732 email_collector.pop_for_recipient(host.email)
2734 # Host should now have 3 notifications (all deferred notifications are delivered on approval):
2735 # 1. host_request:create (the initial request)
2736 # 2. host_request:message (Follow-up message 1)
2737 # 3. host_request:message (Follow-up message 2)
2738 assert push_collector.count_for_user(host.id) == 3
2739 push = push_collector.pop_for_user(host.id, last=False)
2740 assert push.content.title == f"New host request from {surfer.name}"
2743def test_host_request_status_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2744 """
2745 Test that status change notifications (accept/reject/etc.) are NOT sent
2746 for host requests that haven't been approved yet.
2748 Note: In practice, the host can't even SEE the request to accept/reject it
2749 when it's SHADOWED. But if they somehow did, we still shouldn't notify.
2750 """
2751 host, host_token = generate_user(complete_profile=True)
2752 surfer, surfer_token = generate_user(complete_profile=True)
2754 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2755 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2757 # Create host request
2758 with requests_session(surfer_token) as api:
2759 hr_id = api.CreateHostRequest(
2760 requests_pb2.CreateHostRequestReq(
2761 host_user_id=host.id,
2762 from_date=today_plus_2,
2763 to_date=today_plus_3,
2764 text=valid_request_text(),
2765 )
2766 ).host_request_id
2768 # No notifications should have been sent to the host (request is SHADOWED)
2769 assert push_collector.count_for_user(host.id) == 0
2771 # The surfer can cancel their own request even when SHADOWED
2772 # But this should NOT notify the host since the request isn't approved
2773 with requests_session(surfer_token) as api:
2774 api.RespondHostRequest(
2775 requests_pb2.RespondHostRequestReq(
2776 host_request_id=hr_id,
2777 status=messages_pb2.HOST_REQUEST_STATUS_CANCELLED,
2778 text="Actually, never mind",
2779 )
2780 )
2782 # Host should STILL have no notifications (cancel notification suppressed)
2783 assert push_collector.count_for_user(host.id) == 0
2786def test_host_request_notifications_sent_after_approval(
2787 db, email_collector: EmailCollector, push_collector: PushCollector, moderator
2788):
2789 """
2790 Test that after a host request is approved, all notifications work normally.
2791 """
2792 host, host_token = generate_user(complete_profile=True)
2793 surfer, surfer_token = generate_user(complete_profile=True)
2795 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2796 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2798 # Create and approve host request
2799 with requests_session(surfer_token) as api:
2800 hr_id = api.CreateHostRequest(
2801 requests_pb2.CreateHostRequestReq(
2802 host_user_id=host.id,
2803 from_date=today_plus_2,
2804 to_date=today_plus_3,
2805 text=valid_request_text(),
2806 )
2807 ).host_request_id
2809 moderator.approve_host_request(hr_id)
2811 # Host should have received 1 notification (the approval notification)
2812 email_collector.pop_for_recipient(host.email, last=True)
2813 push_collector.pop_for_user(host.id, last=True)
2815 # Host accepts the request - surfer should be notified
2816 with requests_session(host_token) as api:
2817 api.RespondHostRequest(
2818 requests_pb2.RespondHostRequestReq(
2819 host_request_id=hr_id,
2820 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED,
2821 text="Sure, come on over!",
2822 )
2823 )
2825 # Surfer should have 1 notification (the accept notification)
2826 email_collector.pop_for_recipient(surfer.email, last=True)
2827 push = push_collector.pop_for_user(surfer.id, last=True)
2828 assert push.content.title == f"{host.name} accepted your host request"
2830 # Surfer confirms - host should be notified
2831 with requests_session(surfer_token) as api:
2832 api.RespondHostRequest(
2833 requests_pb2.RespondHostRequestReq(
2834 host_request_id=hr_id,
2835 status=messages_pb2.HOST_REQUEST_STATUS_CONFIRMED,
2836 text="See you then!",
2837 )
2838 )
2840 # Host should now have received the confirmation notifications
2841 email_collector.pop_for_recipient(host.email, last=True)
2842 push = push_collector.pop_for_user(host.id, last=True)
2843 assert push.content.title == f"{surfer.name} confirmed their host request"
2846def test_group_chat_message_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2847 """
2848 Test that notifications are NOT sent for messages in group chats
2849 that haven't been approved yet.
2850 """
2851 user1, token1 = generate_user(complete_profile=True)
2852 user2, token2 = generate_user(complete_profile=True)
2854 # Create a group chat (starts in SHADOWED state)
2855 with conversations_session(token1) as api:
2856 res = api.CreateGroupChat(
2857 conversations_pb2.CreateGroupChatReq(
2858 recipient_user_ids=[user2.id],
2859 )
2860 )
2861 gc_id = res.group_chat_id
2863 # Verify initial state
2864 with session_scope() as session:
2865 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2866 assert gc.moderation_state.visibility == ModerationVisibility.shadowed
2868 # No notifications should have been sent yet (chat is SHADOWED)
2869 assert push_collector.count_for_user(user2.id) == 0
2871 # Send messages BEFORE approval
2872 with conversations_session(token1) as api:
2873 api.SendMessage(
2874 conversations_pb2.SendMessageReq(
2875 group_chat_id=gc_id,
2876 text="Hello before approval",
2877 )
2878 )
2880 # Process the queued notification job
2881 while process_job():
2882 pass
2884 # User2 should STILL have no notifications (chat is SHADOWED)
2885 assert push_collector.count_for_user(user2.id) == 0
2887 # Now approve the group chat
2888 moderator.approve_group_chat(gc_id)
2890 # Process the queued notification jobs from approval
2891 while process_job():
2892 pass
2894 # Verify moderation state after approval
2895 with session_scope() as session:
2896 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2897 assert gc.moderation_state.visibility == ModerationVisibility.visible
2899 # User2 should have received 1 notification for the first message sent before approval
2900 push = push_collector.pop_for_user(user2.id, last=True)
2901 assert push.content.title == user1.name
2902 assert push.content.body == "Hello before approval"
2904 # Send a message AFTER approval
2905 with conversations_session(token1) as api:
2906 api.SendMessage(
2907 conversations_pb2.SendMessageReq(
2908 group_chat_id=gc_id,
2909 text="Hello after approval",
2910 )
2911 )
2913 # Process the queued notification job
2914 while process_job():
2915 pass
2917 # User2 should have received another notification
2918 assert push_collector.count_for_user(user2.id) == 1
2921def test_event_moderation_state_content(db):
2922 """Test that event moderation state content includes both title and description"""
2923 super_user, super_token = generate_user(is_superuser=True)
2924 user, token = generate_user()
2926 with session_scope() as session:
2927 create_community(session, 0, 2, "Community", [user], [], None)
2929 start_time = now() + timedelta(hours=2)
2930 end_time = start_time + timedelta(hours=3)
2932 with events_session(token) as api:
2933 res = api.CreateEvent(
2934 events_pb2.CreateEventReq(
2935 title="My Event Title",
2936 content="My event description.",
2937 photo_key=None,
2938 location=events_pb2.EventLocation(
2939 address="Near Null Island",
2940 lat=0.1,
2941 lng=0.2,
2942 ),
2943 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time),
2944 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time),
2945 )
2946 )
2947 event_id = res.event_id
2949 with session_scope() as session:
2950 occurrence = session.execute(select(EventOccurrence).where(EventOccurrence.id == event_id)).scalar_one()
2951 state_id = occurrence.moderation_state_id
2953 with real_moderation_session(super_token) as api:
2954 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
2955 event_items = [
2956 item
2957 for item in res.queue_items
2958 if item.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE
2959 ]
2960 assert len(event_items) == 1
2961 assert event_items[0].moderation_state.content == "My Event Title\n\nMy event description."
2964# ============================================================================
2965# Tests for SetUserContentVisibility
2966# ============================================================================
2969def _get_moderation_state(session, object_type, object_id):
2970 return session.execute(
2971 select(ModerationState)
2972 .where(ModerationState.object_type == object_type)
2973 .where(ModerationState.object_id == object_id)
2974 ).scalar_one()
2977def test_SetUserContentVisibility_host_request(db):
2978 super_user, super_token = generate_user(is_superuser=True)
2979 surfer, surfer_token = generate_user()
2980 host, _ = generate_user()
2982 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2983 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2984 with requests_session(surfer_token) as api:
2985 hr_id = api.CreateHostRequest(
2986 requests_pb2.CreateHostRequestReq(
2987 host_user_id=host.id,
2988 from_date=today_plus_2,
2989 to_date=today_plus_3,
2990 text=valid_request_text(),
2991 )
2992 ).host_request_id
2994 with real_moderation_session(super_token) as api:
2995 res = api.SetUserContentVisibility(
2996 moderation_pb2.SetUserContentVisibilityReq(
2997 user_id=surfer.id,
2998 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2999 )
3000 )
3001 # Already shadowed by default — no-op
3002 assert res.updated_count == 0
3004 with real_moderation_session(super_token) as api:
3005 res = api.SetUserContentVisibility(
3006 moderation_pb2.SetUserContentVisibilityReq(
3007 user_id=surfer.id,
3008 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3009 reason="policy violation",
3010 )
3011 )
3012 assert res.updated_count == 1
3014 with session_scope() as session:
3015 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3016 assert state.visibility == ModerationVisibility.hidden
3018 log_entries = (
3019 session.execute(
3020 select(ModerationLog)
3021 .where(ModerationLog.moderation_state_id == state.id)
3022 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc())
3023 )
3024 .scalars()
3025 .all()
3026 )
3027 # create log + bulk update log
3028 assert len(log_entries) == 2
3029 assert log_entries[-1].new_visibility == ModerationVisibility.hidden
3030 assert log_entries[-1].moderator_user_id == super_user.id
3031 assert log_entries[-1].reason == "policy violation"
3034def test_SetUserContentVisibility_group_chat(db):
3035 super_user, super_token = generate_user(is_superuser=True)
3036 creator, creator_token = generate_user()
3037 other, _ = generate_user()
3038 make_friends(creator, other)
3040 with conversations_session(creator_token) as api:
3041 gc_id = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[other.id])).group_chat_id
3043 with real_moderation_session(super_token) as api:
3044 api.SetUserContentVisibility(
3045 moderation_pb2.SetUserContentVisibilityReq(
3046 user_id=creator.id,
3047 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3048 )
3049 )
3051 with session_scope() as session:
3052 state = _get_moderation_state(session, ModerationObjectType.group_chat, gc_id)
3053 assert state.visibility == ModerationVisibility.hidden
3056def test_SetUserContentVisibility_event_occurrence(db):
3057 super_user, super_token = generate_user(is_superuser=True)
3058 creator, creator_token = generate_user()
3060 with session_scope() as session:
3061 create_community(session, 0, 2, "Community", [creator], [], None)
3063 start_time = now() + timedelta(hours=2)
3064 end_time = start_time + timedelta(hours=3)
3065 with events_session(creator_token) as api:
3066 event_id = api.CreateEvent(
3067 events_pb2.CreateEventReq(
3068 title="Event",
3069 content="Event description.",
3070 photo_key=None,
3071 location=events_pb2.EventLocation(
3072 address="Near Null Island",
3073 lat=0.1,
3074 lng=0.2,
3075 ),
3076 start_datetime_iso8601_local=datetime_to_iso8601_local(start_time),
3077 end_datetime_iso8601_local=datetime_to_iso8601_local(end_time),
3078 )
3079 ).event_id
3081 with real_moderation_session(super_token) as api:
3082 res = api.SetUserContentVisibility(
3083 moderation_pb2.SetUserContentVisibilityReq(
3084 user_id=creator.id,
3085 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3086 )
3087 )
3088 assert res.updated_count == 1
3090 with session_scope() as session:
3091 state = _get_moderation_state(session, ModerationObjectType.event_occurrence, event_id)
3092 assert state.visibility == ModerationVisibility.hidden
3095def test_SetUserContentVisibility_friend_request(db):
3096 super_user, super_token = generate_user(is_superuser=True)
3097 sender, sender_token = generate_user()
3098 recipient, _ = generate_user()
3100 with api_session(sender_token) as api:
3101 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=recipient.id))
3103 with session_scope() as session:
3104 fr_id = session.execute(
3105 select(FriendRelationship.id).where(FriendRelationship.from_user_id == sender.id)
3106 ).scalar_one()
3108 with real_moderation_session(super_token) as api:
3109 res = api.SetUserContentVisibility(
3110 moderation_pb2.SetUserContentVisibilityReq(
3111 user_id=sender.id,
3112 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3113 )
3114 )
3115 assert res.updated_count == 1
3117 with session_scope() as session:
3118 state = _get_moderation_state(session, ModerationObjectType.friend_request, fr_id)
3119 assert state.visibility == ModerationVisibility.hidden
3122def test_SetUserContentVisibility_round_trip(db):
3123 super_user, super_token = generate_user(is_superuser=True)
3124 surfer, surfer_token = generate_user()
3125 host, _ = generate_user()
3127 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3128 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3129 with requests_session(surfer_token) as api:
3130 hr_id = api.CreateHostRequest(
3131 requests_pb2.CreateHostRequestReq(
3132 host_user_id=host.id,
3133 from_date=today_plus_2,
3134 to_date=today_plus_3,
3135 text=valid_request_text(),
3136 )
3137 ).host_request_id
3139 with real_moderation_session(super_token) as api:
3140 api.SetUserContentVisibility(
3141 moderation_pb2.SetUserContentVisibilityReq(
3142 user_id=surfer.id,
3143 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3144 reason="first",
3145 )
3146 )
3147 api.SetUserContentVisibility(
3148 moderation_pb2.SetUserContentVisibilityReq(
3149 user_id=surfer.id,
3150 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3151 reason="second",
3152 )
3153 )
3155 with session_scope() as session:
3156 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3157 assert state.visibility == ModerationVisibility.visible
3159 bulk_log_entries = (
3160 session.execute(
3161 select(ModerationLog)
3162 .where(ModerationLog.moderation_state_id == state.id)
3163 .where(ModerationLog.reason.in_(("first", "second")))
3164 .order_by(ModerationLog.time.asc(), ModerationLog.id.asc())
3165 )
3166 .scalars()
3167 .all()
3168 )
3169 assert [entry.new_visibility for entry in bulk_log_entries] == [
3170 ModerationVisibility.hidden,
3171 ModerationVisibility.visible,
3172 ]
3175def test_SetUserContentVisibility_resolves_queue_items(db):
3176 super_user, super_token = generate_user(is_superuser=True)
3177 surfer, surfer_token = generate_user()
3178 host, _ = generate_user()
3180 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3181 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3182 with requests_session(surfer_token) as api:
3183 hr_id = api.CreateHostRequest(
3184 requests_pb2.CreateHostRequestReq(
3185 host_user_id=host.id,
3186 from_date=today_plus_2,
3187 to_date=today_plus_3,
3188 text=valid_request_text(),
3189 )
3190 ).host_request_id
3192 with session_scope() as session:
3193 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3194 queue_item = session.execute(
3195 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id)
3196 ).scalar_one()
3197 assert queue_item.resolved_by_log_id is None
3199 with real_moderation_session(super_token) as api:
3200 api.SetUserContentVisibility(
3201 moderation_pb2.SetUserContentVisibilityReq(
3202 user_id=surfer.id,
3203 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3204 )
3205 )
3207 with session_scope() as session:
3208 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3209 queue_item = session.execute(
3210 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id)
3211 ).scalar_one()
3212 assert queue_item.resolved_by_log_id is not None
3215def test_SetUserContentVisibility_noop_when_matches(db):
3216 super_user, super_token = generate_user(is_superuser=True)
3217 surfer, surfer_token = generate_user()
3218 host, _ = generate_user()
3220 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3221 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3222 with requests_session(surfer_token) as api:
3223 api.CreateHostRequest(
3224 requests_pb2.CreateHostRequestReq(
3225 host_user_id=host.id,
3226 from_date=today_plus_2,
3227 to_date=today_plus_3,
3228 text=valid_request_text(),
3229 )
3230 )
3232 with session_scope() as session:
3233 log_count_before = len(session.execute(select(ModerationLog)).scalars().all())
3235 with real_moderation_session(super_token) as api:
3236 res = api.SetUserContentVisibility(
3237 moderation_pb2.SetUserContentVisibilityReq(
3238 user_id=surfer.id,
3239 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3240 )
3241 )
3242 assert res.updated_count == 0
3244 with session_scope() as session:
3245 log_count_after = len(session.execute(select(ModerationLog)).scalars().all())
3246 assert log_count_after == log_count_before
3249def test_SetUserContentVisibility_unspecified_rejected(db):
3250 super_user, super_token = generate_user(is_superuser=True)
3251 target, _ = generate_user()
3253 with real_moderation_session(super_token) as api:
3254 with pytest.raises(grpc.RpcError) as e:
3255 api.SetUserContentVisibility(
3256 moderation_pb2.SetUserContentVisibilityReq(
3257 user_id=target.id,
3258 visibility=moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED,
3259 )
3260 )
3261 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
3264def test_SetUserContentVisibility_non_admin_rejected(db):
3265 normal_user, normal_token = generate_user()
3266 target, _ = generate_user()
3268 with real_moderation_session(normal_token) as api:
3269 with pytest.raises(grpc.RpcError) as e:
3270 api.SetUserContentVisibility(
3271 moderation_pb2.SetUserContentVisibilityReq(
3272 user_id=target.id,
3273 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3274 )
3275 )
3276 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED
3279def test_SetUserContentVisibility_writes_admin_action(db):
3280 super_user, super_token = generate_user(is_superuser=True)
3281 surfer, surfer_token = generate_user()
3282 host, _ = generate_user()
3284 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3285 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3286 with requests_session(surfer_token) as api:
3287 api.CreateHostRequest(
3288 requests_pb2.CreateHostRequestReq(
3289 host_user_id=host.id,
3290 from_date=today_plus_2,
3291 to_date=today_plus_3,
3292 text=valid_request_text(),
3293 )
3294 )
3296 with real_moderation_session(super_token) as api:
3297 api.SetUserContentVisibility(
3298 moderation_pb2.SetUserContentVisibilityReq(
3299 user_id=surfer.id,
3300 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3301 reason="bulk hide",
3302 )
3303 )
3305 with session_scope() as session:
3306 actions = (
3307 session.execute(
3308 select(AdminAction)
3309 .where(AdminAction.target_user_id == surfer.id)
3310 .where(AdminAction.action_type == "set_user_content_visibility")
3311 )
3312 .scalars()
3313 .all()
3314 )
3315 assert len(actions) == 1
3316 assert actions[0].admin_user_id == super_user.id
3317 assert actions[0].tag == "hidden"
3318 assert actions[0].note == "bulk hide"
3321def test_SetUserContentVisibility_only_touches_target(db):
3322 super_user, super_token = generate_user(is_superuser=True)
3323 target, target_token = generate_user()
3324 other, other_token = generate_user()
3325 host, _ = generate_user()
3327 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3328 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3330 with requests_session(target_token) as api:
3331 target_hr_id = api.CreateHostRequest(
3332 requests_pb2.CreateHostRequestReq(
3333 host_user_id=host.id,
3334 from_date=today_plus_2,
3335 to_date=today_plus_3,
3336 text=valid_request_text(),
3337 )
3338 ).host_request_id
3340 with requests_session(other_token) as api:
3341 other_hr_id = api.CreateHostRequest(
3342 requests_pb2.CreateHostRequestReq(
3343 host_user_id=host.id,
3344 from_date=today_plus_2,
3345 to_date=today_plus_3,
3346 text=valid_request_text(),
3347 )
3348 ).host_request_id
3350 with real_moderation_session(super_token) as api:
3351 res = api.SetUserContentVisibility(
3352 moderation_pb2.SetUserContentVisibilityReq(
3353 user_id=target.id,
3354 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3355 )
3356 )
3357 assert res.updated_count == 1
3359 with session_scope() as session:
3360 target_state = _get_moderation_state(session, ModerationObjectType.host_request, target_hr_id)
3361 other_state = _get_moderation_state(session, ModerationObjectType.host_request, other_hr_id)
3362 assert target_state.visibility == ModerationVisibility.hidden
3363 assert other_state.visibility == ModerationVisibility.shadowed
3366def test_SetUserContentVisibility_user_not_found(db):
3367 super_user, super_token = generate_user(is_superuser=True)
3369 with real_moderation_session(super_token) as api:
3370 with pytest.raises(grpc.RpcError) as e:
3371 api.SetUserContentVisibility(
3372 moderation_pb2.SetUserContentVisibilityReq(
3373 user_id=999999,
3374 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3375 )
3376 )
3377 assert e.value.code() == grpc.StatusCode.NOT_FOUND
3380def test_SetUserContentVisibility_from_visibility_filter(db):
3381 super_user, super_token = generate_user(is_superuser=True)
3382 surfer, surfer_token = generate_user()
3383 host, _ = generate_user()
3385 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3386 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3387 with requests_session(surfer_token) as api:
3388 hr_id = api.CreateHostRequest(
3389 requests_pb2.CreateHostRequestReq(
3390 host_user_id=host.id,
3391 from_date=today_plus_2,
3392 to_date=today_plus_3,
3393 text=valid_request_text(),
3394 )
3395 ).host_request_id
3397 with real_moderation_session(super_token) as api:
3398 api.SetUserContentVisibility(
3399 moderation_pb2.SetUserContentVisibilityReq(
3400 user_id=surfer.id,
3401 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3402 )
3403 )
3405 with real_moderation_session(super_token) as api:
3406 res = api.SetUserContentVisibility(
3407 moderation_pb2.SetUserContentVisibilityReq(
3408 user_id=surfer.id,
3409 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3410 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_SHADOWED],
3411 )
3412 )
3413 assert res.updated_count == 0
3415 with session_scope() as session:
3416 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3417 assert state.visibility == ModerationVisibility.visible
3419 with real_moderation_session(super_token) as api:
3420 res = api.SetUserContentVisibility(
3421 moderation_pb2.SetUserContentVisibilityReq(
3422 user_id=surfer.id,
3423 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3424 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_VISIBLE],
3425 )
3426 )
3427 assert res.updated_count == 1
3429 with session_scope() as session:
3430 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3431 assert state.visibility == ModerationVisibility.shadowed
3434def test_SetUserContentVisibility_from_visibility_multi(db):
3435 super_user, super_token = generate_user(is_superuser=True)
3436 surfer, surfer_token = generate_user()
3437 host1, _ = generate_user()
3438 host2, _ = generate_user()
3440 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3441 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3442 with requests_session(surfer_token) as api:
3443 hr1_id = api.CreateHostRequest(
3444 requests_pb2.CreateHostRequestReq(
3445 host_user_id=host1.id,
3446 from_date=today_plus_2,
3447 to_date=today_plus_3,
3448 text=valid_request_text(),
3449 )
3450 ).host_request_id
3451 hr2_id = api.CreateHostRequest(
3452 requests_pb2.CreateHostRequestReq(
3453 host_user_id=host2.id,
3454 from_date=today_plus_2,
3455 to_date=today_plus_3,
3456 text=valid_request_text(),
3457 )
3458 ).host_request_id
3460 with session_scope() as session:
3461 state1_id = _get_moderation_state(session, ModerationObjectType.host_request, hr1_id).id
3463 with real_moderation_session(super_token) as api:
3464 api.ModerateContent(
3465 moderation_pb2.ModerateContentReq(
3466 moderation_state_id=state1_id,
3467 action=moderation_pb2.MODERATION_ACTION_APPROVE,
3468 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3469 )
3470 )
3472 with real_moderation_session(super_token) as api:
3473 res = api.SetUserContentVisibility(
3474 moderation_pb2.SetUserContentVisibilityReq(
3475 user_id=surfer.id,
3476 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3477 from_visibility=[
3478 moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
3479 moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
3480 ],
3481 )
3482 )
3483 assert res.updated_count == 2
3485 with session_scope() as session:
3486 state1 = _get_moderation_state(session, ModerationObjectType.host_request, hr1_id)
3487 state2 = _get_moderation_state(session, ModerationObjectType.host_request, hr2_id)
3488 assert state1.visibility == ModerationVisibility.hidden
3489 assert state2.visibility == ModerationVisibility.hidden
3492def test_SetUserContentVisibility_from_visibility_empty_is_any(db):
3493 super_user, super_token = generate_user(is_superuser=True)
3494 surfer, surfer_token = generate_user()
3495 host, _ = generate_user()
3497 today_plus_2 = (today() + timedelta(days=2)).isoformat()
3498 today_plus_3 = (today() + timedelta(days=3)).isoformat()
3499 with requests_session(surfer_token) as api:
3500 hr_id = api.CreateHostRequest(
3501 requests_pb2.CreateHostRequestReq(
3502 host_user_id=host.id,
3503 from_date=today_plus_2,
3504 to_date=today_plus_3,
3505 text=valid_request_text(),
3506 )
3507 ).host_request_id
3509 with real_moderation_session(super_token) as api:
3510 res = api.SetUserContentVisibility(
3511 moderation_pb2.SetUserContentVisibilityReq(
3512 user_id=surfer.id,
3513 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3514 from_visibility=[],
3515 )
3516 )
3517 assert res.updated_count == 1
3519 with session_scope() as session:
3520 state = _get_moderation_state(session, ModerationObjectType.host_request, hr_id)
3521 assert state.visibility == ModerationVisibility.hidden
3524def test_SetUserContentVisibility_from_visibility_unspecified_rejected(db):
3525 super_user, super_token = generate_user(is_superuser=True)
3526 target, _ = generate_user()
3528 with real_moderation_session(super_token) as api:
3529 with pytest.raises(grpc.RpcError) as e:
3530 api.SetUserContentVisibility(
3531 moderation_pb2.SetUserContentVisibilityReq(
3532 user_id=target.id,
3533 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3534 from_visibility=[moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED],
3535 )
3536 )
3537 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
3540def _content_state_ids(res: moderation_pb2.ListModerationStatesRes) -> list[int]:
3541 """Every user has a moderation state, so listings mix them in with the content states."""
3542 return [
3543 s.moderation_state_id
3544 for s in res.moderation_states
3545 if s.object_type != moderation_pb2.MODERATION_OBJECT_TYPE_USER
3546 ]
3549def test_ListModerationStates_with_no_content(db):
3550 super_user, super_token = generate_user(is_superuser=True)
3552 with real_moderation_session(super_token) as api:
3553 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq())
3554 assert [s.object_id for s in res.moderation_states] == [super_user.id]
3555 assert _content_state_ids(res) == []
3556 assert res.next_page_token == ""
3559def test_ListModerationStates_returns_states_chronologically(db):
3560 super_user, super_token = generate_user(is_superuser=True)
3561 surfer, surfer_token = generate_user()
3562 host, _ = generate_user()
3564 state1_id = create_test_host_request_with_moderation(surfer_token, host.id)
3565 state2_id = create_test_host_request_with_moderation(surfer_token, host.id)
3566 state3_id = create_test_host_request_with_moderation(surfer_token, host.id)
3568 with real_moderation_session(super_token) as api:
3569 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq())
3570 assert _content_state_ids(res) == [state1_id, state2_id, state3_id]
3572 res_newest = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(newest_first=True))
3573 assert _content_state_ids(res_newest) == [state3_id, state2_id, state1_id]
3576def test_ListModerationStates_filter_by_author(db):
3577 super_user, super_token = generate_user(is_superuser=True)
3578 surfer1, surfer1_token = generate_user()
3579 surfer2, surfer2_token = generate_user()
3580 host, _ = generate_user()
3582 state1_id = create_test_host_request_with_moderation(surfer1_token, host.id)
3583 state2_id = create_test_host_request_with_moderation(surfer2_token, host.id)
3584 state3_id = create_test_host_request_with_moderation(surfer1_token, host.id)
3586 with real_moderation_session(super_token) as api:
3587 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer1.id))
3588 assert set(_content_state_ids(res)) == {state1_id, state3_id}
3589 user_states = [s for s in res.moderation_states if s.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_USER]
3590 assert [s.object_id for s in user_states] == [surfer1.id]
3592 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer2.id))
3593 assert _content_state_ids(res) == [state2_id]
3596def test_ListModerationStates_pagination(db):
3597 super_user, super_token = generate_user(is_superuser=True)
3598 surfer, surfer_token = generate_user()
3599 host, _ = generate_user()
3601 for _ in range(3):
3602 create_test_host_request_with_moderation(surfer_token, host.id)
3604 with real_moderation_session(super_token) as api:
3605 # 3 users plus 3 host requests
3606 all_ids = [
3607 s.moderation_state_id
3608 for s in api.ListModerationStates(moderation_pb2.ListModerationStatesReq()).moderation_states
3609 ]
3610 assert len(all_ids) == 6
3612 paged_ids = []
3613 page_token = ""
3614 while True:
3615 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(page_size=2, page_token=page_token))
3616 assert len(res.moderation_states) == 2
3617 paged_ids += [s.moderation_state_id for s in res.moderation_states]
3618 page_token = res.next_page_token
3619 if not page_token:
3620 break
3622 assert paged_ids == all_ids
3625def test_ListModerationStates_pagination_ignores_created(db):
3626 """Backfilled states carry the migration's timestamp, so created and id disagree on their order."""
3627 super_user, super_token = generate_user(is_superuser=True)
3628 surfer, surfer_token = generate_user()
3629 host, _ = generate_user()
3631 first_state_id = create_test_host_request_with_moderation(surfer_token, host.id)
3632 create_test_host_request_with_moderation(surfer_token, host.id)
3633 create_test_host_request_with_moderation(surfer_token, host.id)
3635 with session_scope() as session:
3636 state = session.execute(select(ModerationState).where(ModerationState.id == first_state_id)).scalar_one()
3637 state.created = now() + timedelta(days=1)
3639 paged_ids = []
3640 page_token = ""
3641 with real_moderation_session(super_token) as api:
3642 while True:
3643 res = api.ListModerationStates(
3644 moderation_pb2.ListModerationStatesReq(page_size=2, page_token=page_token, newest_first=True)
3645 )
3646 paged_ids += [s.moderation_state_id for s in res.moderation_states]
3647 page_token = res.next_page_token
3648 if not page_token:
3649 break
3651 with session_scope() as session:
3652 expected = list(session.execute(select(ModerationState.id).order_by(ModerationState.id.desc())).scalars().all())
3654 assert paged_ids == expected
3657# ============================================================================
3658# Tests for user account moderation states
3659# ============================================================================
3662def test_user_moderation_state_created_with_the_account(db):
3663 user, _ = generate_user()
3665 with session_scope() as session:
3666 state = session.execute(
3667 select(ModerationState)
3668 .where(ModerationState.object_type == ModerationObjectType.user)
3669 .where(ModerationState.object_id == user.id)
3670 ).scalar_one()
3672 assert state.visibility is None
3673 assert session.execute(select(User.moderation_state_id).where(User.id == user.id)).scalar_one() == state.id
3675 log_entries = (
3676 session.execute(select(ModerationLog).where(ModerationLog.moderation_state_id == state.id)).scalars().all()
3677 )
3678 assert len(log_entries) == 1
3679 assert log_entries[0].action == ModerationAction.create
3680 assert log_entries[0].moderator_user_id == user.id
3682 queue_items = (
3683 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state.id))
3684 .scalars()
3685 .all()
3686 )
3687 assert queue_items == []
3690def test_GetModerationState_for_user(db):
3691 super_user, super_token = generate_user(is_superuser=True)
3692 user, _ = generate_user()
3694 with real_moderation_session(super_token) as api:
3695 state = api.GetModerationState(
3696 moderation_pb2.GetModerationStateReq(
3697 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER,
3698 object_id=user.id,
3699 )
3700 ).moderation_state
3701 assert state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_USER
3702 assert state.object_id == user.id
3703 assert state.author_user_id == user.id
3704 assert state.author.user_id == user.id
3705 assert state.content == f"@{user.username} / {user.id}"
3706 assert state.visibility == moderation_pb2.MODERATION_VISIBILITY_UNSPECIFIED
3709def test_GetModerationState_for_nonexistent_user(db):
3710 super_user, super_token = generate_user(is_superuser=True)
3712 with real_moderation_session(super_token) as api:
3713 with pytest.raises(grpc.RpcError) as e:
3714 api.GetModerationState(
3715 moderation_pb2.GetModerationStateReq(
3716 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER,
3717 object_id=999999,
3718 )
3719 )
3720 assert e.value.code() == grpc.StatusCode.NOT_FOUND
3723def test_flag_user_shows_up_in_queue_and_author_filters(db):
3724 super_user, super_token = generate_user(is_superuser=True)
3725 surfer, surfer_token = generate_user()
3726 host, _ = generate_user()
3727 other, _ = generate_user()
3729 content_state_id = create_test_host_request_with_moderation(surfer_token, host.id)
3731 with real_moderation_session(super_token) as api:
3732 user_state_id = api.GetModerationState(
3733 moderation_pb2.GetModerationStateReq(
3734 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER,
3735 object_id=surfer.id,
3736 )
3737 ).moderation_state.moderation_state_id
3739 api.ModerateContent(
3740 moderation_pb2.ModerateContentReq(
3741 moderation_state_id=user_state_id,
3742 action=moderation_pb2.MODERATION_ACTION_FLAG,
3743 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
3744 reason="Reported for spamming hosts",
3745 )
3746 )
3748 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=surfer.id))
3749 assert {item.moderation_state_id for item in res.queue_items} == {content_state_id, user_state_id}
3750 user_item = next(item for item in res.queue_items if item.moderation_state_id == user_state_id)
3751 assert user_item.trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG
3752 assert user_item.reason == "Reported for spamming hosts"
3753 assert user_item.moderation_state.author.user_id == surfer.id
3755 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=other.id))
3756 assert len(res.queue_items) == 0
3758 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=surfer.id))
3759 assert {s.moderation_state_id for s in res.moderation_states} == {content_state_id, user_state_id}
3761 res = api.ListModerationStates(moderation_pb2.ListModerationStatesReq(author_user_id=other.id))
3762 assert [s.object_id for s in res.moderation_states] == [other.id]
3765@pytest.mark.parametrize("action", [moderation_pb2.MODERATION_ACTION_APPROVE, moderation_pb2.MODERATION_ACTION_HIDE])
3766def test_ModerateContent_cannot_set_visibility_on_user_state(db, action):
3767 super_user, super_token = generate_user(is_superuser=True)
3768 user, _ = generate_user()
3770 with real_moderation_session(super_token) as api:
3771 user_state_id = api.GetModerationState(
3772 moderation_pb2.GetModerationStateReq(
3773 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER,
3774 object_id=user.id,
3775 )
3776 ).moderation_state.moderation_state_id
3778 with pytest.raises(grpc.RpcError) as e:
3779 api.ModerateContent(
3780 moderation_pb2.ModerateContentReq(
3781 moderation_state_id=user_state_id,
3782 action=action,
3783 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3784 )
3785 )
3786 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
3788 with session_scope() as session:
3789 state = session.execute(select(ModerationState).where(ModerationState.id == user_state_id)).scalar_one()
3790 assert state.visibility is None
3793def test_SetUserContentVisibility_leaves_user_state_alone(db):
3794 super_user, super_token = generate_user(is_superuser=True)
3795 surfer, surfer_token = generate_user()
3796 host, _ = generate_user()
3798 content_state_id = create_test_host_request_with_moderation(surfer_token, host.id)
3800 with real_moderation_session(super_token) as api:
3801 user_state_id = api.GetModerationState(
3802 moderation_pb2.GetModerationStateReq(
3803 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_USER,
3804 object_id=surfer.id,
3805 )
3806 ).moderation_state.moderation_state_id
3808 res = api.SetUserContentVisibility(
3809 moderation_pb2.SetUserContentVisibilityReq(
3810 user_id=surfer.id,
3811 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
3812 )
3813 )
3814 assert res.updated_count == 1
3816 with session_scope() as session:
3817 content_state = session.execute(
3818 select(ModerationState).where(ModerationState.id == content_state_id)
3819 ).scalar_one()
3820 assert content_state.visibility == ModerationVisibility.hidden
3822 user_state = session.execute(select(ModerationState).where(ModerationState.id == user_state_id)).scalar_one()
3823 assert user_state.visibility is None
3826# ============================================================================
3827# Tests for the moderation model metadata
3828# ============================================================================
3831def test_every_object_type_has_a_model():
3832 """A type in the enum with no model behind it is accepted everywhere and understood nowhere."""
3833 assert set(get_moderated_models()) == set(ModerationObjectType)
3836@pytest.mark.parametrize("object_type", list(ModerationObjectType))
3837def test_visibility_check_constraint_matches_the_models(db, object_type):
3838 """The check constraint spells out the types with their own visibility mechanism; this makes sure it agrees with the models."""
3839 has_own_visibility_mechanism = get_moderated_models()[object_type].has_own_visibility_mechanism
3841 def add_state(object_id: int, visibility: ModerationVisibility | None) -> None:
3842 with session_scope() as session:
3843 session.add(ModerationState(object_type=object_type, object_id=object_id, visibility=visibility))
3845 if has_own_visibility_mechanism:
3846 add_state(1, None)
3847 with pytest.raises(IntegrityError):
3848 add_state(2, ModerationVisibility.visible)
3849 else:
3850 add_state(1, ModerationVisibility.visible)
3851 with pytest.raises(IntegrityError):
3852 add_state(2, None)