Coverage for app / backend / src / tests / test_moderation.py: 100%
1011 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
1"""
2Comprehensive tests for the Unified Moderation System (UMS)
3"""
5from datetime import datetime, timedelta
7import grpc
8import pytest
9from google.protobuf import empty_pb2
10from sqlalchemy.sql import select
12from couchers.config import config
13from couchers.db import session_scope
14from couchers.jobs.handlers import auto_approve_moderation_queue
15from couchers.models import (
16 EventOccurrence,
17 GroupChat,
18 HostRequest,
19 ModerationAction,
20 ModerationLog,
21 ModerationObjectType,
22 ModerationQueueItem,
23 ModerationState,
24 ModerationTrigger,
25 ModerationVisibility,
26)
27from couchers.moderation.utils import create_moderation
28from couchers.proto import conversations_pb2, events_pb2, moderation_pb2, notifications_pb2, requests_pb2
29from couchers.utils import Timestamp_from_datetime, now, today
30from tests.fixtures.db import generate_user, make_friends
31from tests.fixtures.misc import PushCollector, mock_notification_email, process_jobs
32from tests.fixtures.sessions import (
33 conversations_session,
34 events_session,
35 notifications_session,
36 real_moderation_session,
37 requests_session,
38)
39from tests.test_communities import create_community
40from tests.test_requests import valid_request_text
43@pytest.fixture(autouse=True)
44def _(testconfig):
45 pass
48def create_test_host_request_with_moderation(surfer_token, host_user_id):
49 """Helper to create a host request and return its moderation state ID"""
50 today_plus_2 = (today() + timedelta(days=2)).isoformat()
51 today_plus_3 = (today() + timedelta(days=3)).isoformat()
53 with requests_session(surfer_token) as api:
54 hr_id = api.CreateHostRequest(
55 requests_pb2.CreateHostRequestReq(
56 host_user_id=host_user_id,
57 from_date=today_plus_2,
58 to_date=today_plus_3,
59 text=valid_request_text(),
60 )
61 ).host_request_id
63 with session_scope() as session:
64 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
65 return hr.moderation_state_id
68# ============================================================================
69# Tests for moderation helper functions
70# ============================================================================
73def test_create_moderation(db):
74 """Test creating a moderation state with associated log entry"""
75 user, _ = generate_user()
77 with session_scope() as session:
78 # Create a moderation state
79 moderation_state = create_moderation(
80 session=session,
81 object_type=ModerationObjectType.host_request,
82 object_id=123,
83 creator_user_id=user.id,
84 )
86 assert moderation_state.object_type == ModerationObjectType.host_request
87 assert moderation_state.object_id == 123
88 assert moderation_state.visibility == ModerationVisibility.shadowed
90 # Check that log entry was created
91 log_entries = (
92 session.execute(select(ModerationLog).where(ModerationLog.moderation_state_id == moderation_state.id))
93 .scalars()
94 .all()
95 )
97 assert len(log_entries) == 1
98 assert log_entries[0].action == ModerationAction.create
99 assert log_entries[0].reason == "Object created."
100 assert log_entries[0].moderator_user_id == user.id
103def test_add_to_moderation_queue(db):
104 """Test adding content to moderation queue via API"""
105 super_user, super_token = generate_user(is_superuser=True)
106 user1, token1 = generate_user()
107 user2, _ = generate_user()
109 today_plus_2 = (today() + timedelta(days=2)).isoformat()
110 today_plus_3 = (today() + timedelta(days=3)).isoformat()
112 # Create a real host request (which automatically creates moderation state and adds to queue)
113 with requests_session(token1) as api:
114 host_request_id = api.CreateHostRequest(
115 requests_pb2.CreateHostRequestReq(
116 host_user_id=user2.id,
117 from_date=today_plus_2,
118 to_date=today_plus_3,
119 text=valid_request_text(),
120 )
121 ).host_request_id
123 # Get the moderation state ID
124 state_id = None
125 with session_scope() as session:
126 host_request = session.execute(
127 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
128 ).scalar_one()
129 state_id = host_request.moderation_state_id
131 # Add another item to moderation queue via API (the first one was created automatically)
132 with real_moderation_session(super_token) as api:
133 res = api.FlagContentForReview(
134 moderation_pb2.FlagContentForReviewReq(
135 moderation_state_id=state_id,
136 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
137 reason="Admin manually flagged for additional review",
138 )
139 )
141 assert res.queue_item.moderation_state_id == state_id
142 assert res.queue_item.trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG
143 assert res.queue_item.reason == "Admin manually flagged for additional review"
144 assert res.queue_item.moderation_state.author_user_id == user1.id
145 assert res.queue_item.is_resolved == False
148def test_moderate_content(db):
149 """Test moderating content via API"""
150 super_user, super_token = generate_user(is_superuser=True)
151 user, token = generate_user()
152 host, _ = generate_user()
154 today_plus_2 = (today() + timedelta(days=2)).isoformat()
155 today_plus_3 = (today() + timedelta(days=3)).isoformat()
157 # Create a real host request
158 state_id = None
159 with requests_session(token) as api:
160 hr_id = api.CreateHostRequest(
161 requests_pb2.CreateHostRequestReq(
162 host_user_id=host.id,
163 from_date=today_plus_2,
164 to_date=today_plus_3,
165 text=valid_request_text(),
166 )
167 ).host_request_id
169 with session_scope() as session:
170 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
171 state_id = hr.moderation_state_id
173 # Moderate the content via API
174 with real_moderation_session(super_token) as api:
175 res = api.ModerateContent(
176 moderation_pb2.ModerateContentReq(
177 moderation_state_id=state_id,
178 action=moderation_pb2.MODERATION_ACTION_APPROVE,
179 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
180 reason="Content looks good",
181 )
182 )
184 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
186 # Check that state was updated in database
187 with session_scope() as session:
188 updated_state = session.get_one(ModerationState, state_id)
189 assert updated_state.visibility == ModerationVisibility.visible
191 # Check that log entry was created
192 log_entries = (
193 session.execute(
194 select(ModerationLog)
195 .where(ModerationLog.moderation_state_id == state_id)
196 .order_by(ModerationLog.time.desc(), ModerationLog.id.desc())
197 )
198 .scalars()
199 .all()
200 )
202 assert len(log_entries) == 2 # CREATE + APPROVE
203 assert log_entries[0].action == ModerationAction.approve
204 assert log_entries[0].moderator_user_id == super_user.id
205 assert log_entries[0].reason == "Content looks good"
208def test_resolve_queue_item(db):
209 """Test resolving a moderation queue item via ModerateContent API"""
210 user1, token1 = generate_user()
211 user2, _ = generate_user()
212 moderator, moderator_token = generate_user(is_superuser=True)
214 today_plus_2 = (today() + timedelta(days=2)).isoformat()
215 today_plus_3 = (today() + timedelta(days=3)).isoformat()
217 # Create a host request using the API (which automatically creates moderation state)
218 with requests_session(token1) as api:
219 host_request_id = api.CreateHostRequest(
220 requests_pb2.CreateHostRequestReq(
221 host_user_id=user2.id,
222 from_date=today_plus_2,
223 to_date=today_plus_3,
224 text=valid_request_text(),
225 )
226 ).host_request_id
228 state_id = None
229 with session_scope() as session:
230 # Get the host request and its moderation state
231 host_request = session.execute(
232 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
233 ).scalar_one()
234 state_id = host_request.moderation_state_id
236 # The moderation state should already exist and be in the queue
237 queue_item = session.execute(
238 select(ModerationQueueItem)
239 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
240 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
241 ).scalar_one()
243 assert queue_item.resolved_by_log_id is None
245 # Approve content via API (which should resolve the queue item)
246 with real_moderation_session(moderator_token) as api:
247 api.ModerateContent(
248 moderation_pb2.ModerateContentReq(
249 moderation_state_id=state_id,
250 action=moderation_pb2.MODERATION_ACTION_APPROVE,
251 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
252 reason="Approved after review",
253 )
254 )
256 # Check that queue item was resolved
257 with session_scope() as session:
258 queue_item = session.execute(
259 select(ModerationQueueItem)
260 .where(ModerationQueueItem.moderation_state_id == state_id)
261 .where(ModerationQueueItem.resolved_by_log_id.is_not(None))
262 ).scalar_one()
263 assert queue_item.resolved_by_log_id is not None
266def test_approve_content_via_api(db):
267 """Test approving content via ModerateContent API"""
268 user1, token1 = generate_user()
269 user2, _ = generate_user()
270 moderator, moderator_token = generate_user(is_superuser=True)
272 today_plus_2 = (today() + timedelta(days=2)).isoformat()
273 today_plus_3 = (today() + timedelta(days=3)).isoformat()
275 # Create a host request using the API (which automatically creates moderation state)
276 with requests_session(token1) as api:
277 host_request_id = api.CreateHostRequest(
278 requests_pb2.CreateHostRequestReq(
279 host_user_id=user2.id,
280 from_date=today_plus_2,
281 to_date=today_plus_3,
282 text=valid_request_text(),
283 )
284 ).host_request_id
286 state_id = None
287 with session_scope() as session:
288 # Get the host request and its moderation state
289 host_request = session.execute(
290 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
291 ).scalar_one()
292 state_id = host_request.moderation_state_id
294 # Approve via API
295 with real_moderation_session(moderator_token) as api:
296 api.ModerateContent(
297 moderation_pb2.ModerateContentReq(
298 moderation_state_id=state_id,
299 action=moderation_pb2.MODERATION_ACTION_APPROVE,
300 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
301 reason="Quick approval",
302 )
303 )
305 # Check that state was updated to VISIBLE
306 with session_scope() as session:
307 updated_state = session.get_one(ModerationState, state_id)
308 assert updated_state.visibility == ModerationVisibility.visible
310 # Check log entry
311 log_entry = session.execute(
312 select(ModerationLog)
313 .where(ModerationLog.moderation_state_id == state_id)
314 .where(ModerationLog.action == ModerationAction.approve)
315 ).scalar_one()
317 assert log_entry.moderator_user_id == moderator.id
318 assert log_entry.reason == "Quick approval"
321# ============================================================================
322# Tests for host request moderation integration
323# ============================================================================
326def test_create_host_request_creates_moderation_state(db):
327 """Test that creating a host request automatically creates a moderation state"""
328 user1, token1 = generate_user()
329 user2, token2 = generate_user()
331 today_plus_2 = (today() + timedelta(days=2)).isoformat()
332 today_plus_3 = (today() + timedelta(days=3)).isoformat()
334 with requests_session(token1) as api:
335 host_request_id = api.CreateHostRequest(
336 requests_pb2.CreateHostRequestReq(
337 host_user_id=user2.id,
338 from_date=today_plus_2,
339 to_date=today_plus_3,
340 text=valid_request_text(),
341 )
342 ).host_request_id
344 with session_scope() as session:
345 # Check that host request has a moderation state
346 host_request = session.execute(
347 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
348 ).scalar_one()
350 # Check moderation state properties
351 moderation_state = session.execute(
352 select(ModerationState).where(ModerationState.id == host_request.moderation_state_id)
353 ).scalar_one()
355 assert moderation_state.object_type == ModerationObjectType.host_request
356 assert moderation_state.object_id == host_request_id
357 assert moderation_state.visibility == ModerationVisibility.shadowed
359 # Check that it was added to moderation queue
360 queue_items = (
361 session.execute(
362 select(ModerationQueueItem)
363 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
364 .where(ModerationQueueItem.resolved_by_log_id == None)
365 )
366 .scalars()
367 .all()
368 )
370 assert len(queue_items) == 1
371 assert queue_items[0].trigger == ModerationTrigger.initial_review
372 # item_author_user_id is no longer stored in the model, it's dynamically retrieved
375def test_host_request_no_notification_before_approval(db, push_collector: PushCollector):
376 """Test that host requests don't send notifications until approved"""
377 user1, token1 = generate_user()
378 user2, token2 = generate_user()
380 today_plus_2 = (today() + timedelta(days=2)).isoformat()
381 today_plus_3 = (today() + timedelta(days=3)).isoformat()
383 with requests_session(token1) as api:
384 host_request_id = api.CreateHostRequest(
385 requests_pb2.CreateHostRequestReq(
386 host_user_id=user2.id,
387 from_date=today_plus_2,
388 to_date=today_plus_3,
389 text=valid_request_text(),
390 )
391 ).host_request_id
393 # Process all jobs (including the notification job)
394 process_jobs()
396 # No push notification should be sent yet (host requests are shadowed initially)
397 assert push_collector.count_for_user(user2.id) == 0
400def test_shadowed_notification_not_in_list_notifications(db):
401 """Test that notifications for shadowed content don't appear in ListNotifications API"""
402 user1, token1 = generate_user()
403 user2, token2 = generate_user()
405 today_plus_2 = (today() + timedelta(days=2)).isoformat()
406 today_plus_3 = (today() + timedelta(days=3)).isoformat()
408 # Create a host request (which creates a shadowed notification for the host)
409 with requests_session(token1) as api:
410 host_request_id = api.CreateHostRequest(
411 requests_pb2.CreateHostRequestReq(
412 host_user_id=user2.id,
413 from_date=today_plus_2,
414 to_date=today_plus_3,
415 text=valid_request_text(),
416 )
417 ).host_request_id
419 # Host (recipient) should NOT see the notification in ListNotifications - it's for shadowed content
420 with notifications_session(token2) as api:
421 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
422 # Should be empty - the host request is still shadowed
423 assert len(res.notifications) == 0
426def test_notification_visible_after_approval(db):
427 """Test that notifications appear in ListNotifications after content is approved"""
428 user1, token1 = generate_user()
429 user2, token2 = generate_user()
430 mod, mod_token = generate_user(is_superuser=True)
432 today_plus_2 = (today() + timedelta(days=2)).isoformat()
433 today_plus_3 = (today() + timedelta(days=3)).isoformat()
435 # Create a host request (which creates a shadowed notification for the host)
436 with requests_session(token1) as api:
437 host_request_id = api.CreateHostRequest(
438 requests_pb2.CreateHostRequestReq(
439 host_user_id=user2.id,
440 from_date=today_plus_2,
441 to_date=today_plus_3,
442 text=valid_request_text(),
443 )
444 ).host_request_id
446 # Host (recipient) should NOT see the notification initially
447 with notifications_session(token2) as api:
448 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
449 assert len(res.notifications) == 0
451 # Get the moderation state ID and approve
452 with session_scope() as session:
453 host_request = session.execute(
454 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
455 ).scalar_one()
456 state_id = host_request.moderation_state_id
458 with real_moderation_session(mod_token) as api:
459 api.ModerateContent(
460 moderation_pb2.ModerateContentReq(
461 moderation_state_id=state_id,
462 action=moderation_pb2.MODERATION_ACTION_APPROVE,
463 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
464 reason="Looks good",
465 )
466 )
468 # Now host SHOULD see the notification
469 with notifications_session(token2) as api:
470 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
471 assert len(res.notifications) == 1
472 assert res.notifications[0].topic == "host_request"
473 assert res.notifications[0].action == "create"
476def test_shadowed_host_request_visible_to_author_only(db):
477 """Test that SHADOWED host requests are visible only to the author (surfer), not the recipient (host)"""
478 user1, token1 = generate_user()
479 user2, token2 = generate_user()
481 today_plus_2 = (today() + timedelta(days=2)).isoformat()
482 today_plus_3 = (today() + timedelta(days=3)).isoformat()
484 with requests_session(token1) as api:
485 host_request_id = api.CreateHostRequest(
486 requests_pb2.CreateHostRequestReq(
487 host_user_id=user2.id,
488 from_date=today_plus_2,
489 to_date=today_plus_3,
490 text=valid_request_text(),
491 )
492 ).host_request_id
494 # Surfer (author) can see it with GetHostRequest
495 with requests_session(token1) as api:
496 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
497 assert res.host_request_id == host_request_id
498 assert res.latest_message.text.text == valid_request_text()
500 # Host (recipient) CANNOT see it with GetHostRequest - it's shadowed
501 with requests_session(token2) as api:
502 with pytest.raises(grpc.RpcError) as e:
503 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
504 assert e.value.code() == grpc.StatusCode.NOT_FOUND
507def test_unlisted_host_request_not_in_lists(db):
508 """Test that SHADOWED host requests are visible to author but not to recipient"""
509 user1, token1 = generate_user()
510 user2, token2 = generate_user()
512 today_plus_2 = (today() + timedelta(days=2)).isoformat()
513 today_plus_3 = (today() + timedelta(days=3)).isoformat()
515 with requests_session(token1) as api:
516 host_request_id = api.CreateHostRequest(
517 requests_pb2.CreateHostRequestReq(
518 host_user_id=user2.id,
519 from_date=today_plus_2,
520 to_date=today_plus_3,
521 text=valid_request_text(),
522 )
523 ).host_request_id
525 # Surfer (author) should see it in their sent list even though it's SHADOWED
526 with requests_session(token1) as api:
527 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
528 assert len(res.host_requests) == 1
530 # Host should NOT see it in their received list (still SHADOWED from them)
531 with requests_session(token2) as api:
532 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
533 assert len(res.host_requests) == 0
536def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector):
537 """Test that approved host requests appear in lists and send notifications"""
538 user1, token1 = generate_user()
539 user2, token2 = generate_user()
540 mod, mod_token = generate_user(is_superuser=True)
542 today_plus_2 = (today() + timedelta(days=2)).isoformat()
543 today_plus_3 = (today() + timedelta(days=3)).isoformat()
545 with requests_session(token1) as api:
546 host_request_id = api.CreateHostRequest(
547 requests_pb2.CreateHostRequestReq(
548 host_user_id=user2.id,
549 from_date=today_plus_2,
550 to_date=today_plus_3,
551 text=valid_request_text(),
552 )
553 ).host_request_id
555 # Process the initial notification job - should be deferred (no notification sent)
556 process_jobs()
557 assert push_collector.count_for_user(user2.id) == 0
559 # Get the moderation state ID
560 state_id = None
561 with session_scope() as session:
562 host_request = session.execute(
563 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
564 ).scalar_one()
565 state_id = host_request.moderation_state_id
567 # Approve the host request via API
568 with real_moderation_session(mod_token) as api:
569 api.ModerateContent(
570 moderation_pb2.ModerateContentReq(
571 moderation_state_id=state_id,
572 action=moderation_pb2.MODERATION_ACTION_APPROVE,
573 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
574 reason="Looks good",
575 )
576 )
578 # Process the re-queued notification job - should now send notification
579 process_jobs()
581 # Now surfer SHOULD see it in their sent list
582 with requests_session(token1) as api:
583 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
584 assert len(res.host_requests) == 1
585 assert res.host_requests[0].host_request_id == host_request_id
587 # Host SHOULD see it in their received list
588 with requests_session(token2) as api:
589 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
590 assert len(res.host_requests) == 1
591 assert res.host_requests[0].host_request_id == host_request_id
593 # After approval, the host should have received a push notification
594 assert push_collector.pop_for_user(user2.id, last=True).topic_action == "host_request:create"
597def test_hidden_host_request_invisible_to_all(db):
598 """Test that HIDDEN host requests are invisible to everyone except moderators"""
599 user1, token1 = generate_user()
600 user2, token2 = generate_user()
601 user3, token3 = generate_user() # Third party
602 moderator, moderator_token = generate_user(is_superuser=True)
604 today_plus_2 = (today() + timedelta(days=2)).isoformat()
605 today_plus_3 = (today() + timedelta(days=3)).isoformat()
607 with requests_session(token1) as api:
608 host_request_id = api.CreateHostRequest(
609 requests_pb2.CreateHostRequestReq(
610 host_user_id=user2.id,
611 from_date=today_plus_2,
612 to_date=today_plus_3,
613 text=valid_request_text(),
614 )
615 ).host_request_id
617 # Get the moderation state ID
618 state_id = None
619 with session_scope() as session:
620 host_request = session.execute(
621 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
622 ).scalar_one()
623 state_id = host_request.moderation_state_id
625 # Hide the host request via API (e.g., spam/abuse)
626 with real_moderation_session(moderator_token) as api:
627 api.ModerateContent(
628 moderation_pb2.ModerateContentReq(
629 moderation_state_id=state_id,
630 action=moderation_pb2.MODERATION_ACTION_HIDE,
631 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
632 reason="Spam content",
633 )
634 )
636 # Surfer can't see it with GetHostRequest
637 with requests_session(token1) as api:
638 with pytest.raises(grpc.RpcError) as e:
639 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
640 assert e.value.code() == grpc.StatusCode.NOT_FOUND
642 # Host can't see it with GetHostRequest
643 with requests_session(token2) as api:
644 with pytest.raises(grpc.RpcError) as e:
645 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
646 assert e.value.code() == grpc.StatusCode.NOT_FOUND
648 # Third party definitely can't see it
649 with requests_session(token3) as api:
650 with pytest.raises(grpc.RpcError) as e:
651 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
652 assert e.value.code() == grpc.StatusCode.NOT_FOUND
654 # Not in any lists
655 with requests_session(token1) as api:
656 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
657 assert len(res.host_requests) == 0
659 with requests_session(token2) as api:
660 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
661 assert len(res.host_requests) == 0
664def test_multiple_host_requests_listing_visibility(db):
665 """Test that ListHostRequests correctly filters based on moderation state"""
666 user1, token1 = generate_user()
667 user2, token2 = generate_user()
668 moderator, moderator_token = generate_user(is_superuser=True)
670 today_plus_2 = (today() + timedelta(days=2)).isoformat()
671 today_plus_3 = (today() + timedelta(days=3)).isoformat()
673 # Create 3 host requests
674 host_request_ids = []
675 state_ids = []
676 with requests_session(token1) as api:
677 for i in range(3):
678 hr_id = api.CreateHostRequest(
679 requests_pb2.CreateHostRequestReq(
680 host_user_id=user2.id,
681 from_date=today_plus_2,
682 to_date=today_plus_3,
683 text=valid_request_text(f"Test request {i + 1}"),
684 )
685 ).host_request_id
686 host_request_ids.append(hr_id)
688 # Get state IDs
689 with session_scope() as session:
690 for hr_id in host_request_ids:
691 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
692 state_ids.append(host_request.moderation_state_id)
694 # Approve the first one via API
695 with real_moderation_session(moderator_token) as api:
696 api.ModerateContent(
697 moderation_pb2.ModerateContentReq(
698 moderation_state_id=state_ids[0],
699 action=moderation_pb2.MODERATION_ACTION_APPROVE,
700 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
701 reason="Approved",
702 )
703 )
705 # Hide the third one via API
706 with real_moderation_session(moderator_token) as api:
707 api.ModerateContent(
708 moderation_pb2.ModerateContentReq(
709 moderation_state_id=state_ids[2],
710 action=moderation_pb2.MODERATION_ACTION_HIDE,
711 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
712 reason="Spam",
713 )
714 )
716 # Surfer should see the approved one and the shadowed one (author can see their SHADOWED content)
717 with requests_session(token1) as api:
718 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
719 assert len(res.host_requests) == 2
720 visible_ids = {hr.host_request_id for hr in res.host_requests}
721 assert visible_ids == {host_request_ids[0], host_request_ids[1]}
723 # Host should see only the approved one in received list
724 with requests_session(token2) as api:
725 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
726 assert len(res.host_requests) == 1
727 assert res.host_requests[0].host_request_id == host_request_ids[0]
730def test_moderation_log_tracking(db):
731 """Test that moderation actions are properly logged via API"""
732 user, user_token = generate_user()
733 host, _ = generate_user()
734 moderator1, moderator1_token = generate_user(is_superuser=True)
735 moderator2, moderator2_token = generate_user(is_superuser=True)
737 # Create a real host request
738 state_id = create_test_host_request_with_moderation(user_token, host.id)
740 # Perform several moderation actions via API
741 with real_moderation_session(moderator1_token) as api:
742 api.ModerateContent(
743 moderation_pb2.ModerateContentReq(
744 moderation_state_id=state_id,
745 action=moderation_pb2.MODERATION_ACTION_APPROVE,
746 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
747 reason="Looks good initially",
748 )
749 )
751 with real_moderation_session(moderator2_token) as api:
752 api.FlagContentForReview(
753 moderation_pb2.FlagContentForReviewReq(
754 moderation_state_id=state_id,
755 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
756 reason="Wait, this needs another look",
757 )
758 )
759 # Shadow it back
760 api.ModerateContent(
761 moderation_pb2.ModerateContentReq(
762 moderation_state_id=state_id,
763 action=moderation_pb2.MODERATION_ACTION_HIDE,
764 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
765 reason="Wait, this needs another look",
766 )
767 )
769 with real_moderation_session(moderator1_token) as api:
770 api.ModerateContent(
771 moderation_pb2.ModerateContentReq(
772 moderation_state_id=state_id,
773 action=moderation_pb2.MODERATION_ACTION_HIDE,
774 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
775 reason="Actually it's spam",
776 )
777 )
779 # Check all log entries
780 with session_scope() as session:
781 log_entries = (
782 session.execute(
783 select(ModerationLog)
784 .where(ModerationLog.moderation_state_id == state_id)
785 .order_by(ModerationLog.time.asc())
786 )
787 .scalars()
788 .all()
789 )
791 # CREATE + APPROVE + HIDE + HIDE (shadowing back counts as HIDE action)
792 assert len(log_entries) >= 3
794 assert log_entries[0].action == ModerationAction.create
795 assert log_entries[0].moderator_user_id == user.id
796 assert log_entries[0].reason == "Object created."
798 assert log_entries[1].action == ModerationAction.approve
799 assert log_entries[1].moderator_user_id == moderator1.id
800 assert log_entries[1].reason == "Looks good initially"
802 # The last action should be hiding
803 assert log_entries[-1].action == ModerationAction.hide
804 assert log_entries[-1].moderator_user_id == moderator1.id
805 assert log_entries[-1].reason == "Actually it's spam"
808def test_moderation_queue_workflow(db):
809 """Test the full moderation queue workflow via API"""
810 user1, token1 = generate_user()
811 user2, _ = generate_user()
812 moderator, moderator_token = generate_user(is_superuser=True)
814 today_plus_2 = (today() + timedelta(days=2)).isoformat()
815 today_plus_3 = (today() + timedelta(days=3)).isoformat()
817 # Create a host request using the API (which automatically creates moderation state and adds to queue)
818 with requests_session(token1) as api:
819 host_request_id = api.CreateHostRequest(
820 requests_pb2.CreateHostRequestReq(
821 host_user_id=user2.id,
822 from_date=today_plus_2,
823 to_date=today_plus_3,
824 text=valid_request_text(),
825 )
826 ).host_request_id
828 state_id = None
829 queue_item_id = None
830 with session_scope() as session:
831 # Get the host request and its moderation state
832 host_request = session.execute(
833 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
834 ).scalar_one()
835 state_id = host_request.moderation_state_id
837 # The queue item should already exist (created automatically)
838 queue_item = session.execute(
839 select(ModerationQueueItem)
840 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
841 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
842 ).scalar_one()
843 queue_item_id = queue_item.id
845 # Verify it's in the queue
846 unresolved_items = (
847 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
848 .scalars()
849 .all()
850 )
852 assert len(unresolved_items) >= 1
853 assert queue_item.id in [item.id for item in unresolved_items]
855 # Moderator reviews and approves via API (which also resolves the queue item)
856 with real_moderation_session(moderator_token) as api:
857 api.ModerateContent(
858 moderation_pb2.ModerateContentReq(
859 moderation_state_id=state_id,
860 action=moderation_pb2.MODERATION_ACTION_APPROVE,
861 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
862 reason="Content approved",
863 )
864 )
866 # Verify queue item was resolved
867 with session_scope() as session:
868 # Verify it's no longer in unresolved queue
869 unresolved_items = (
870 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
871 .scalars()
872 .all()
873 )
875 assert queue_item_id not in [item.id for item in unresolved_items]
877 # Verify the queue item was linked to a log entry
878 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
879 assert queue_item.resolved_by_log_id is not None
882# ============================================================================
883# Moderation API Tests (testing the gRPC servicer)
884# ============================================================================
887def test_GetModerationQueue_empty(db):
888 """Test getting an empty moderation queue"""
889 super_user, super_token = generate_user(is_superuser=True)
891 with real_moderation_session(super_token) as api:
892 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
893 assert len(res.queue_items) == 0
894 assert res.next_page_token == ""
897def test_GetModerationQueue_with_items(db):
898 """Test getting moderation queue with items via API"""
899 super_user, super_token = generate_user(is_superuser=True)
900 normal_user, user_token = generate_user()
901 host, _ = generate_user()
903 # Create some host requests (which automatically adds them to moderation queue)
904 state1_id = create_test_host_request_with_moderation(user_token, host.id)
905 state2_id = create_test_host_request_with_moderation(user_token, host.id)
907 with real_moderation_session(super_token) as api:
908 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
909 assert len(res.queue_items) == 2
910 assert res.queue_items[0].is_resolved == False
911 assert res.queue_items[1].is_resolved == False
914def test_GetModerationQueue_filter_by_trigger(db):
915 """Test filtering moderation queue by trigger type via API"""
916 super_user, super_token = generate_user(is_superuser=True)
917 normal_user, user_token = generate_user()
918 host, _ = generate_user()
920 # Create host requests (which automatically adds them to moderation queue with INITIAL_REVIEW)
921 state1_id = create_test_host_request_with_moderation(user_token, host.id)
922 state2_id = create_test_host_request_with_moderation(user_token, host.id)
924 # Add USER_FLAG trigger to second item via API
925 with real_moderation_session(super_token) as api:
926 api.FlagContentForReview(
927 moderation_pb2.FlagContentForReviewReq(
928 moderation_state_id=state2_id,
929 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
930 reason="Reported by user",
931 )
932 )
934 # Filter by INITIAL_REVIEW (should get first item and maybe both depending on how queue works)
935 with real_moderation_session(super_token) as api:
936 res = api.GetModerationQueue(
937 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW])
938 )
939 assert len(res.queue_items) == 2 # Both have INITIAL_REVIEW triggers
940 assert all(item.trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW for item in res.queue_items)
942 # Filter by USER_FLAG (should get second item only)
943 with real_moderation_session(super_token) as api:
944 res = api.GetModerationQueue(
945 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_USER_FLAG])
946 )
947 assert len(res.queue_items) == 1
948 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG
951def test_GetModerationQueue_filter_created_before(db):
952 """Test filtering moderation queue by created_before timestamp"""
953 super_user, super_token = generate_user(is_superuser=True)
954 normal_user, user_token = generate_user()
955 host, _ = generate_user()
957 # Create host requests
958 state1_id = create_test_host_request_with_moderation(user_token, host.id)
959 state2_id = create_test_host_request_with_moderation(user_token, host.id)
961 # Backdate the first queue item
962 with session_scope() as session:
963 queue_item1 = session.execute(
964 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
965 ).scalar_one()
966 # Set it to 2 hours ago
967 queue_item1.time_created = now() - timedelta(hours=2)
969 # The second item remains at current time
971 # Filter to items created before 1 hour ago (should only get the first item)
972 cutoff_time = now() - timedelta(hours=1)
973 with real_moderation_session(super_token) as api:
974 res = api.GetModerationQueue(
975 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(cutoff_time))
976 )
977 assert len(res.queue_items) == 1
978 assert res.queue_items[0].moderation_state_id == state1_id
980 # Filter to items created before now (should get both)
981 with real_moderation_session(super_token) as api:
982 res = api.GetModerationQueue(
983 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(now() + timedelta(seconds=10)))
984 )
985 assert len(res.queue_items) == 2
987 # Filter to items created before 3 hours ago (should get none)
988 old_cutoff = now() - timedelta(hours=3)
989 with real_moderation_session(super_token) as api:
990 res = api.GetModerationQueue(
991 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(old_cutoff))
992 )
993 assert len(res.queue_items) == 0
996def test_GetModerationQueue_filter_created_after(db):
997 """Test filtering moderation queue by created_after timestamp"""
998 super_user, super_token = generate_user(is_superuser=True)
999 normal_user, user_token = generate_user()
1000 host, _ = generate_user()
1002 # Create host requests
1003 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1004 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1006 # Backdate the first queue item to 2 hours ago
1007 with session_scope() as session:
1008 queue_item1 = session.execute(
1009 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1010 ).scalar_one()
1011 queue_item1.time_created = now() - timedelta(hours=2)
1013 # The second item remains at current time
1015 # Filter to items created after 1 hour ago (should only get the second item)
1016 cutoff_time = now() - timedelta(hours=1)
1017 with real_moderation_session(super_token) as api:
1018 res = api.GetModerationQueue(
1019 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(cutoff_time))
1020 )
1021 assert len(res.queue_items) == 1
1022 assert res.queue_items[0].moderation_state_id == state2_id
1024 # Filter to items created after 3 hours ago (should get both)
1025 old_cutoff = now() - timedelta(hours=3)
1026 with real_moderation_session(super_token) as api:
1027 res = api.GetModerationQueue(
1028 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(old_cutoff))
1029 )
1030 assert len(res.queue_items) == 2
1032 # Filter to items created after now (should get none)
1033 with real_moderation_session(super_token) as api:
1034 res = api.GetModerationQueue(
1035 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(now() + timedelta(seconds=10)))
1036 )
1037 assert len(res.queue_items) == 0
1040def test_GetModerationQueue_filter_created_before_and_after(db):
1041 """Test filtering moderation queue by both created_before and created_after timestamps"""
1042 super_user, super_token = generate_user(is_superuser=True)
1043 normal_user, user_token = generate_user()
1044 host, _ = generate_user()
1046 # Create 3 host requests
1047 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1048 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1049 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1051 # Set different times: state1 = 3 hours ago, state2 = 1.5 hours ago, state3 = now
1052 with session_scope() as session:
1053 queue_item1 = session.execute(
1054 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1055 ).scalar_one()
1056 queue_item1.time_created = now() - timedelta(hours=3)
1058 queue_item2 = session.execute(
1059 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1060 ).scalar_one()
1061 queue_item2.time_created = now() - timedelta(hours=1, minutes=30)
1063 # Filter to items between 2 hours ago and 1 hour ago (should only get state2)
1064 after_cutoff = now() - timedelta(hours=2)
1065 before_cutoff = now() - timedelta(hours=1)
1066 with real_moderation_session(super_token) as api:
1067 res = api.GetModerationQueue(
1068 moderation_pb2.GetModerationQueueReq(
1069 created_after=Timestamp_from_datetime(after_cutoff),
1070 created_before=Timestamp_from_datetime(before_cutoff),
1071 )
1072 )
1073 assert len(res.queue_items) == 1
1074 assert res.queue_items[0].moderation_state_id == state2_id
1076 # Filter to items between 4 hours ago and 2.5 hours ago (should only get state1)
1077 after_cutoff = now() - timedelta(hours=4)
1078 before_cutoff = now() - timedelta(hours=2, minutes=30)
1079 with real_moderation_session(super_token) as api:
1080 res = api.GetModerationQueue(
1081 moderation_pb2.GetModerationQueueReq(
1082 created_after=Timestamp_from_datetime(after_cutoff),
1083 created_before=Timestamp_from_datetime(before_cutoff),
1084 )
1085 )
1086 assert len(res.queue_items) == 1
1087 assert res.queue_items[0].moderation_state_id == state1_id
1090def test_GetModerationQueue_filter_unresolved(db):
1091 """Test filtering moderation queue for unresolved items only via API"""
1092 super_user, super_token = generate_user(is_superuser=True)
1093 normal_user, user_token = generate_user()
1094 host, _ = generate_user()
1096 # Create 2 host requests
1097 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1098 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1100 # Resolve the first one via API (ModerateContent automatically resolves queue items)
1101 with real_moderation_session(super_token) as api:
1102 api.ModerateContent(
1103 moderation_pb2.ModerateContentReq(
1104 moderation_state_id=state1_id,
1105 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1106 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1107 reason="Approved",
1108 )
1109 )
1111 # Get all items
1112 with real_moderation_session(super_token) as api:
1113 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1114 assert len(res.queue_items) == 2
1116 # Get only unresolved items
1117 with real_moderation_session(super_token) as api:
1118 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1119 assert len(res.queue_items) == 1
1120 assert res.queue_items[0].is_resolved == False
1123def test_GetModerationQueue_filter_by_author(db):
1124 """Test filtering moderation queue by item_author_user_id"""
1125 super_user, super_token = generate_user(is_superuser=True)
1126 user1, token1 = generate_user()
1127 user2, token2 = generate_user()
1128 host_user, _ = generate_user()
1130 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1131 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1133 # Create 2 host requests by user1
1134 with requests_session(token1) as api:
1135 hr1_id = api.CreateHostRequest(
1136 requests_pb2.CreateHostRequestReq(
1137 host_user_id=host_user.id,
1138 from_date=today_plus_2,
1139 to_date=today_plus_3,
1140 text=valid_request_text(),
1141 )
1142 ).host_request_id
1144 hr2_id = api.CreateHostRequest(
1145 requests_pb2.CreateHostRequestReq(
1146 host_user_id=host_user.id,
1147 from_date=today_plus_2,
1148 to_date=today_plus_3,
1149 text=valid_request_text(),
1150 )
1151 ).host_request_id
1153 # Create 1 host request by user2
1154 with requests_session(token2) as api:
1155 hr3_id = api.CreateHostRequest(
1156 requests_pb2.CreateHostRequestReq(
1157 host_user_id=host_user.id,
1158 from_date=today_plus_2,
1159 to_date=today_plus_3,
1160 text=valid_request_text(),
1161 )
1162 ).host_request_id
1164 # Get moderation state IDs
1165 state1_id, state2_id, state3_id = None, None, None
1166 with session_scope() as session:
1167 hr1 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr1_id)).scalar_one()
1168 hr2 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr2_id)).scalar_one()
1169 hr3 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr3_id)).scalar_one()
1170 state1_id = hr1.moderation_state_id
1171 state2_id = hr2.moderation_state_id
1172 state3_id = hr3.moderation_state_id
1174 # Get all items (should be 3)
1175 with real_moderation_session(super_token) as api:
1176 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1177 assert len(res.queue_items) == 3
1179 # Filter by user1 (should get 2)
1180 with real_moderation_session(super_token) as api:
1181 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user1.id))
1182 assert len(res.queue_items) == 2
1183 assert all(item.moderation_state.author_user_id == user1.id for item in res.queue_items)
1185 # Filter by user2 (should get 1)
1186 with real_moderation_session(super_token) as api:
1187 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user2.id))
1188 assert len(res.queue_items) == 1
1189 assert res.queue_items[0].moderation_state.author_user_id == user2.id
1190 assert res.queue_items[0].moderation_state_id == state3_id
1192 # Filter by non-existent user (should get 0)
1193 with real_moderation_session(super_token) as api:
1194 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=999999))
1195 assert len(res.queue_items) == 0
1198def test_GetModerationQueue_ordering(db):
1199 """Test ordering moderation queue by oldest/newest first"""
1200 super_user, super_token = generate_user(is_superuser=True)
1201 normal_user, user_token = generate_user()
1202 host, _ = generate_user()
1204 # Create 3 host requests
1205 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1206 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1207 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1209 # Set different times: state1 = 3 hours ago, state2 = 2 hours ago, state3 = 1 hour ago
1210 with session_scope() as session:
1211 queue_item1 = session.execute(
1212 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1213 ).scalar_one()
1214 queue_item1.time_created = now() - timedelta(hours=3)
1216 queue_item2 = session.execute(
1217 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1218 ).scalar_one()
1219 queue_item2.time_created = now() - timedelta(hours=2)
1221 queue_item3 = session.execute(
1222 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state3_id)
1223 ).scalar_one()
1224 queue_item3.time_created = now() - timedelta(hours=1)
1226 # Default order (oldest first)
1227 with real_moderation_session(super_token) as api:
1228 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1229 assert len(res.queue_items) == 3
1230 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1231 assert res.queue_items[1].moderation_state_id == state2_id
1232 assert res.queue_items[2].moderation_state_id == state3_id # newest
1234 # Explicit oldest first
1235 with real_moderation_session(super_token) as api:
1236 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=False))
1237 assert len(res.queue_items) == 3
1238 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1239 assert res.queue_items[2].moderation_state_id == state3_id # newest
1241 # Newest first
1242 with real_moderation_session(super_token) as api:
1243 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=True))
1244 assert len(res.queue_items) == 3
1245 assert res.queue_items[0].moderation_state_id == state3_id # newest
1246 assert res.queue_items[1].moderation_state_id == state2_id
1247 assert res.queue_items[2].moderation_state_id == state1_id # oldest
1250def test_GetModerationQueue_pagination_newest_first(db):
1251 """Test pagination with newest_first=True returns different items on each page"""
1252 super_user, super_token = generate_user(is_superuser=True)
1253 normal_user, normal_token = generate_user()
1254 host_user, _ = generate_user()
1256 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1257 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1259 # Create 5 host requests
1260 hr_ids = []
1261 with requests_session(normal_token) as api:
1262 for i in range(5):
1263 hr_id = api.CreateHostRequest(
1264 requests_pb2.CreateHostRequestReq(
1265 host_user_id=host_user.id,
1266 from_date=today_plus_2,
1267 to_date=today_plus_3,
1268 text=valid_request_text(),
1269 )
1270 ).host_request_id
1271 hr_ids.append(hr_id)
1273 # Get moderation state IDs
1274 state_ids = []
1275 with session_scope() as session:
1276 for hr_id in hr_ids:
1277 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
1278 state_ids.append(hr.moderation_state_id)
1280 # Set different times so ordering is deterministic
1281 with session_scope() as session:
1282 for i, state_id in enumerate(state_ids):
1283 queue_item = session.execute(
1284 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
1285 ).scalar_one()
1286 queue_item.time_created = now() - timedelta(hours=5 - i) # oldest first in list
1288 # Get first page (2 items) with newest_first=True, filtered to our user's items
1289 with real_moderation_session(super_token) as api:
1290 res1 = api.GetModerationQueue(
1291 moderation_pb2.GetModerationQueueReq(page_size=2, newest_first=True, item_author_user_id=normal_user.id)
1292 )
1293 assert len(res1.queue_items) == 2
1294 # Should get newest items: state_ids[4], state_ids[3]
1295 assert res1.queue_items[0].moderation_state_id == state_ids[4]
1296 assert res1.queue_items[1].moderation_state_id == state_ids[3]
1297 assert res1.next_page_token # should have more pages
1299 # Get second page using the token
1300 res2 = api.GetModerationQueue(
1301 moderation_pb2.GetModerationQueueReq(
1302 page_size=2, newest_first=True, page_token=res1.next_page_token, item_author_user_id=normal_user.id
1303 )
1304 )
1305 assert len(res2.queue_items) == 2
1306 # Should get next newest items: state_ids[2], state_ids[1]
1307 assert res2.queue_items[0].moderation_state_id == state_ids[2]
1308 assert res2.queue_items[1].moderation_state_id == state_ids[1]
1310 # Pages should not overlap
1311 page1_ids = {item.moderation_state_id for item in res1.queue_items}
1312 page2_ids = {item.moderation_state_id for item in res2.queue_items}
1313 assert page1_ids.isdisjoint(page2_ids), "Pages should not have overlapping items"
1316def test_GetModerationLog(db):
1317 """Test getting moderation log for a state via API"""
1318 super_user, super_token = generate_user(is_superuser=True)
1319 moderator, moderator_token = generate_user(is_superuser=True)
1320 normal_user, user_token = generate_user()
1321 host, _ = generate_user()
1323 # Create a real host request
1324 state_id = create_test_host_request_with_moderation(user_token, host.id)
1326 # Perform a moderation action via API
1327 with real_moderation_session(moderator_token) as api:
1328 api.ModerateContent(
1329 moderation_pb2.ModerateContentReq(
1330 moderation_state_id=state_id,
1331 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1332 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1333 reason="Looks good",
1334 )
1335 )
1337 with real_moderation_session(super_token) as api:
1338 res = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
1339 assert len(res.log_entries) == 2 # CREATE + APPROVE
1340 assert res.moderation_state.moderation_state_id == state_id
1341 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1342 # Log entries are in reverse chronological order
1343 assert res.log_entries[0].action == moderation_pb2.MODERATION_ACTION_APPROVE
1344 assert res.log_entries[0].moderator_user_id == moderator.id
1345 assert res.log_entries[0].reason == "Looks good"
1346 assert res.log_entries[1].action == moderation_pb2.MODERATION_ACTION_CREATE
1347 assert res.log_entries[1].moderator_user_id == normal_user.id
1350def test_GetModerationLog_not_found(db):
1351 """Test getting moderation log for non-existent state"""
1352 super_user, super_token = generate_user(is_superuser=True)
1354 with real_moderation_session(super_token) as api:
1355 with pytest.raises(grpc.RpcError) as e:
1356 api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=999999))
1357 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1358 assert e.value.details() == "Moderation state not found."
1361def test_GetModerationState(db):
1362 """Test getting moderation state by object type and ID"""
1363 super_user, super_token = generate_user(is_superuser=True)
1364 user1, token1 = generate_user()
1365 user2, _ = generate_user()
1367 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1368 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1370 with requests_session(token1) as api:
1371 host_request_id = api.CreateHostRequest(
1372 requests_pb2.CreateHostRequestReq(
1373 host_user_id=user2.id,
1374 from_date=today_plus_2,
1375 to_date=today_plus_3,
1376 text=valid_request_text(),
1377 )
1378 ).host_request_id
1380 with real_moderation_session(super_token) as api:
1381 res = api.GetModerationState(
1382 moderation_pb2.GetModerationStateReq(
1383 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1384 object_id=host_request_id,
1385 )
1386 )
1387 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST
1388 assert res.moderation_state.object_id == host_request_id
1389 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1390 assert res.moderation_state.moderation_state_id > 0
1393def test_GetModerationState_not_found(db):
1394 """Test getting moderation state for non-existent object"""
1395 super_user, super_token = generate_user(is_superuser=True)
1397 with real_moderation_session(super_token) as api:
1398 with pytest.raises(grpc.RpcError) as e:
1399 api.GetModerationState(
1400 moderation_pb2.GetModerationStateReq(
1401 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1402 object_id=999999,
1403 )
1404 )
1405 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1406 assert e.value.details() == "Moderation state not found."
1409def test_GetModerationState_unspecified_type(db):
1410 """Test getting moderation state with unspecified object type"""
1411 super_user, super_token = generate_user(is_superuser=True)
1413 with real_moderation_session(super_token) as api:
1414 with pytest.raises(grpc.RpcError) as e:
1415 api.GetModerationState(
1416 moderation_pb2.GetModerationStateReq(
1417 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED,
1418 object_id=123,
1419 )
1420 )
1421 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1422 assert e.value.details() == "Object type must be specified."
1425def test_ModerateContent_approve(db):
1426 """Test approving content via unified moderation API"""
1427 super_user, super_token = generate_user(is_superuser=True)
1428 user1, token1 = generate_user()
1429 user2, _ = generate_user()
1431 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1432 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1434 # Create a host request using the API (which automatically creates moderation state)
1435 with requests_session(token1) as api:
1436 host_request_id = api.CreateHostRequest(
1437 requests_pb2.CreateHostRequestReq(
1438 host_user_id=user2.id,
1439 from_date=today_plus_2,
1440 to_date=today_plus_3,
1441 text=valid_request_text(),
1442 )
1443 ).host_request_id
1445 # Get the moderation state ID
1446 state_id = None
1447 with session_scope() as session:
1448 host_request = session.execute(
1449 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1450 ).scalar_one()
1451 state_id = host_request.moderation_state_id
1453 with real_moderation_session(super_token) as api:
1454 res = api.ModerateContent(
1455 moderation_pb2.ModerateContentReq(
1456 moderation_state_id=state_id,
1457 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1458 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1459 reason="Approved by admin",
1460 )
1461 )
1462 assert res.moderation_state.moderation_state_id == state_id
1463 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1465 # Verify state was updated in database
1466 with session_scope() as session:
1467 state = session.get_one(ModerationState, state_id)
1468 assert state.visibility == ModerationVisibility.visible
1471def test_ModerateContent_not_found(db):
1472 """Test moderating non-existent content"""
1473 super_user, super_token = generate_user(is_superuser=True)
1475 with real_moderation_session(super_token) as api:
1476 with pytest.raises(grpc.RpcError) as e:
1477 api.ModerateContent(
1478 moderation_pb2.ModerateContentReq(
1479 moderation_state_id=999999,
1480 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1481 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1482 reason="Test",
1483 )
1484 )
1485 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1486 assert e.value.details() == "Moderation state not found."
1489def test_ModerateContent_hide(db):
1490 """Test hiding content via unified moderation API"""
1491 super_user, super_token = generate_user(is_superuser=True)
1492 normal_user, user_token = generate_user()
1493 host, _ = generate_user()
1495 # Create a real host request
1496 state_id = create_test_host_request_with_moderation(user_token, host.id)
1498 with real_moderation_session(super_token) as api:
1499 res = api.ModerateContent(
1500 moderation_pb2.ModerateContentReq(
1501 moderation_state_id=state_id,
1502 action=moderation_pb2.MODERATION_ACTION_HIDE,
1503 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1504 reason="Spam content",
1505 )
1506 )
1507 assert res.moderation_state.moderation_state_id == state_id
1508 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_HIDDEN
1510 # Verify state was updated in database
1511 with session_scope() as session:
1512 state = session.get_one(ModerationState, state_id)
1513 assert state.visibility == ModerationVisibility.hidden
1516def test_ModerateContent_shadow(db):
1517 """Test shadowing content via unified moderation API"""
1518 super_user, super_token = generate_user(is_superuser=True)
1519 normal_user, user_token = generate_user()
1520 host, _ = generate_user()
1522 # Create a real host request
1523 state_id = create_test_host_request_with_moderation(user_token, host.id)
1525 with real_moderation_session(super_token) as api:
1526 res = api.ModerateContent(
1527 moderation_pb2.ModerateContentReq(
1528 moderation_state_id=state_id,
1529 action=moderation_pb2.MODERATION_ACTION_HIDE,
1530 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
1531 reason="Needs further review",
1532 )
1533 )
1534 assert res.moderation_state.moderation_state_id == state_id
1535 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1537 # Verify state was updated in database
1538 with session_scope() as session:
1539 state = session.get_one(ModerationState, state_id)
1540 assert state.visibility == ModerationVisibility.shadowed
1543def test_FlagContentForReview(db):
1544 """Test flagging content for review via admin API"""
1545 super_user, super_token = generate_user(is_superuser=True)
1546 user1, token1 = generate_user()
1547 user2, _ = generate_user()
1549 # Create a host request
1550 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1551 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1553 with requests_session(token1) as api:
1554 host_request_id = api.CreateHostRequest(
1555 requests_pb2.CreateHostRequestReq(
1556 host_user_id=user2.id,
1557 from_date=today_plus_2,
1558 to_date=today_plus_3,
1559 text=valid_request_text(),
1560 )
1561 ).host_request_id
1563 # Get the moderation state ID
1564 with session_scope() as session:
1565 host_request = session.execute(
1566 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1567 ).scalar_one()
1568 state_id = host_request.moderation_state_id
1570 with real_moderation_session(super_token) as api:
1571 res = api.FlagContentForReview(
1572 moderation_pb2.FlagContentForReviewReq(
1573 moderation_state_id=state_id,
1574 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
1575 reason="Admin flagged for additional review",
1576 )
1577 )
1578 assert res.queue_item.moderation_state_id == state_id
1579 assert res.queue_item.trigger == moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW
1580 assert res.queue_item.is_resolved == False
1582 # Verify queue item was created in database
1583 with session_scope() as session:
1584 # Get the most recent queue item (the one we just created)
1585 queue_item = (
1586 session.execute(
1587 select(ModerationQueueItem)
1588 .where(ModerationQueueItem.moderation_state_id == state_id)
1589 .order_by(ModerationQueueItem.time_created.desc())
1590 )
1591 .scalars()
1592 .first()
1593 )
1594 assert queue_item
1595 assert queue_item.trigger == ModerationTrigger.moderator_review
1596 assert queue_item.resolved_by_log_id is None
1599# ============================================================================
1600# Tests for group chat moderation
1601# ============================================================================
1604def test_group_chat_created_with_moderation_state(db):
1605 """Test that group chats are created with moderation state"""
1606 user1, token1 = generate_user()
1607 user2, _ = generate_user()
1608 make_friends(user1, user2)
1610 with conversations_session(token1) as api:
1611 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1612 group_chat_id = res.group_chat_id
1614 # Verify moderation state was created
1615 with session_scope() as session:
1616 group_chat = session.execute(select(GroupChat).where(GroupChat.conversation_id == group_chat_id)).scalar_one()
1618 assert group_chat.moderation_state.object_type == ModerationObjectType.group_chat
1619 assert group_chat.moderation_state.object_id == group_chat_id
1620 # Group chats start as SHADOWED
1621 assert group_chat.moderation_state.visibility == ModerationVisibility.shadowed
1623 # A moderation queue item should have been created
1624 queue_item = (
1625 session.execute(
1626 select(ModerationQueueItem).where(
1627 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id
1628 )
1629 )
1630 .scalars()
1631 .first()
1632 )
1633 assert queue_item is not None
1634 assert queue_item.trigger == ModerationTrigger.initial_review
1637def test_group_chat_GetModerationState(db):
1638 """Test GetModerationState API for group chats"""
1639 user1, token1 = generate_user()
1640 user2, _ = generate_user()
1641 moderator, mod_token = generate_user(is_superuser=True)
1642 make_friends(user1, user2)
1644 with conversations_session(token1) as api:
1645 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1646 group_chat_id = res.group_chat_id
1648 # Moderator can look up the moderation state
1649 with real_moderation_session(mod_token) as api:
1650 res = api.GetModerationState(
1651 moderation_pb2.GetModerationStateReq(
1652 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1653 object_id=group_chat_id,
1654 )
1655 )
1656 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT
1657 assert res.moderation_state.object_id == group_chat_id
1658 # Starts as SHADOWED
1659 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1662def test_group_chat_moderation_hide(db):
1663 """Test that a moderator can hide a group chat and participants can no longer see it"""
1664 user1, token1 = generate_user()
1665 user2, token2 = generate_user()
1666 moderator, mod_token = generate_user(is_superuser=True)
1667 make_friends(user1, user2)
1669 with conversations_session(token1) as api:
1670 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1671 group_chat_id = res.group_chat_id
1672 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
1674 # First approve the group chat so both users can see it
1675 with real_moderation_session(mod_token) as api:
1676 state_res = api.GetModerationState(
1677 moderation_pb2.GetModerationStateReq(
1678 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1679 object_id=group_chat_id,
1680 )
1681 )
1682 api.ModerateContent(
1683 moderation_pb2.ModerateContentReq(
1684 moderation_state_id=state_res.moderation_state.moderation_state_id,
1685 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1686 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1687 reason="Approved",
1688 )
1689 )
1691 # Both users can see the chat now
1692 with conversations_session(token1) as api:
1693 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1694 assert len(res.group_chats) == 1
1696 with conversations_session(token2) as api:
1697 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1698 assert len(res.group_chats) == 1
1700 # Moderator hides the group chat
1701 with real_moderation_session(mod_token) as api:
1702 state_res = api.GetModerationState(
1703 moderation_pb2.GetModerationStateReq(
1704 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1705 object_id=group_chat_id,
1706 )
1707 )
1708 api.ModerateContent(
1709 moderation_pb2.ModerateContentReq(
1710 moderation_state_id=state_res.moderation_state.moderation_state_id,
1711 action=moderation_pb2.MODERATION_ACTION_HIDE,
1712 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1713 reason="Inappropriate content",
1714 )
1715 )
1717 # Neither user can see the chat now
1718 with conversations_session(token1) as api:
1719 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1720 assert len(res.group_chats) == 0
1722 with conversations_session(token2) as api:
1723 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1724 assert len(res.group_chats) == 0
1726 # Trying to get messages returns empty (chat is hidden so no messages visible)
1727 with conversations_session(token1) as api:
1728 res = api.GetGroupChatMessages(conversations_pb2.GetGroupChatMessagesReq(group_chat_id=group_chat_id))
1729 assert len(res.messages) == 0
1732def test_group_chat_moderation_shadow(db):
1733 """Test that shadowing a group chat hides it from non-creator participants"""
1734 user1, token1 = generate_user() # Creator
1735 user2, token2 = generate_user() # Participant
1736 moderator, mod_token = generate_user(is_superuser=True)
1737 make_friends(user1, user2)
1739 with conversations_session(token1) as api:
1740 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1741 group_chat_id = res.group_chat_id
1742 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
1744 # Moderator shadows the group chat
1745 with real_moderation_session(mod_token) as api:
1746 state_res = api.GetModerationState(
1747 moderation_pb2.GetModerationStateReq(
1748 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1749 object_id=group_chat_id,
1750 )
1751 )
1752 api.ModerateContent(
1753 moderation_pb2.ModerateContentReq(
1754 moderation_state_id=state_res.moderation_state.moderation_state_id,
1755 action=moderation_pb2.MODERATION_ACTION_HIDE,
1756 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
1757 reason="Needs review",
1758 )
1759 )
1761 # Creator can see SHADOWED content in list operations
1762 with conversations_session(token1) as api:
1763 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1764 assert len(res.group_chats) == 1
1765 assert res.group_chats[0].group_chat_id == group_chat_id
1767 # But non-creator participant cannot see it in lists
1768 with conversations_session(token2) as api:
1769 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1770 assert len(res.group_chats) == 0
1772 # Creator can also access it directly via GetGroupChat
1773 with conversations_session(token1) as api:
1774 res = api.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id))
1775 assert res.group_chat_id == group_chat_id
1778# ============================================================================
1779# Tests for auto-approval background job
1780# ============================================================================
1783def test_auto_approve_moderation_queue_disabled_when_zero(db):
1784 """Test that auto-approval is disabled when deadline is 0"""
1785 moderator, mod_token = generate_user(is_superuser=True)
1786 user1, token1 = generate_user()
1787 user2, token2 = generate_user()
1789 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1790 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1792 # Create a host request
1793 with requests_session(token1) as api:
1794 with mock_notification_email() as mock:
1795 host_request_id = api.CreateHostRequest(
1796 requests_pb2.CreateHostRequestReq(
1797 host_user_id=user2.id,
1798 from_date=today_plus_2,
1799 to_date=today_plus_3,
1800 text=valid_request_text(),
1801 )
1802 ).host_request_id
1804 # No email should have been sent (request is shadowed)
1805 mock.assert_not_called()
1807 # Ensure deadline is 0 (disabled)
1808 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 0
1810 # Run the job
1811 auto_approve_moderation_queue(empty_pb2.Empty())
1813 # Surfer (author) can see the request via API
1814 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1815 assert res.host_request_id == host_request_id
1817 # Author can see their SHADOWED request in their sent list
1818 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
1819 assert len(res.host_requests) == 1
1820 assert res.host_requests[0].host_request_id == host_request_id
1822 # Host cannot see the request (it's shadowed from them)
1823 with requests_session(token2) as api:
1824 with pytest.raises(grpc.RpcError) as e:
1825 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1826 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1828 # Host doesn't see it in their received list either
1829 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
1830 assert len(res.host_requests) == 0
1832 # Moderator can still see the item in the moderation queue
1833 with real_moderation_session(mod_token) as api:
1834 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1835 assert len(res.queue_items) == 1
1836 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW
1838 # Moderator can check the state is still SHADOWED
1839 state_res = api.GetModerationState(
1840 moderation_pb2.GetModerationStateReq(
1841 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1842 object_id=host_request_id,
1843 )
1844 )
1845 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1848def test_auto_approve_moderation_queue_approves_old_items(db, push_collector: PushCollector):
1849 """Test that auto-approval approves items older than the deadline"""
1850 moderator, mod_token = generate_user(is_superuser=True)
1851 user1, token1 = generate_user()
1852 user2, token2 = generate_user()
1854 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1855 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1857 # Create a host request
1858 with requests_session(token1) as api:
1859 with mock_notification_email() as mock:
1860 host_request_id = api.CreateHostRequest(
1861 requests_pb2.CreateHostRequestReq(
1862 host_user_id=user2.id,
1863 from_date=today_plus_2,
1864 to_date=today_plus_3,
1865 text=valid_request_text("Test request for auto-approval"),
1866 )
1867 ).host_request_id
1869 # No email sent initially (shadowed)
1870 mock.assert_not_called()
1872 # Host cannot see the request yet
1873 with requests_session(token2) as api:
1874 with pytest.raises(grpc.RpcError) as e:
1875 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1876 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1878 # Make the queue item appear old by backdating its time_created
1879 with session_scope() as session:
1880 host_request = session.execute(
1881 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1882 ).scalar_one()
1883 queue_item = session.execute(
1884 select(ModerationQueueItem)
1885 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
1886 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1887 ).scalar_one()
1888 # Backdate the queue item by 2 minutes
1889 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=2)
1891 # Set deadline to 60 seconds (items older than 60 seconds will be auto-approved)
1892 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 60
1893 config["MODERATION_BOT_USER_ID"] = moderator.id
1895 # Run the job
1896 auto_approve_moderation_queue(empty_pb2.Empty())
1898 # Now host can see the request via API
1899 with requests_session(token2) as api:
1900 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1901 assert res.host_request_id == host_request_id
1902 assert res.latest_message.text.text == valid_request_text("Test request for auto-approval")
1904 # Host sees it in their received list
1905 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
1906 assert len(res.host_requests) == 1
1907 assert res.host_requests[0].host_request_id == host_request_id
1909 # Surfer sees it in their sent list
1910 with requests_session(token1) as api:
1911 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
1912 assert len(res.host_requests) == 1
1913 assert res.host_requests[0].host_request_id == host_request_id
1915 # Moderator sees the queue item is now resolved
1916 with real_moderation_session(mod_token) as api:
1917 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1918 assert len(res.queue_items) == 0
1920 # State is now VISIBLE
1921 state_res = api.GetModerationState(
1922 moderation_pb2.GetModerationStateReq(
1923 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1924 object_id=host_request_id,
1925 )
1926 )
1927 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1929 # Check the log shows auto-approval by the bot user
1930 log_res = api.GetModerationLog(
1931 moderation_pb2.GetModerationLogReq(moderation_state_id=state_res.moderation_state.moderation_state_id)
1932 )
1933 # Find the APPROVE action
1934 approve_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_APPROVE]
1935 assert len(approve_entries) == 1
1936 assert "Auto-approved" in approve_entries[0].reason
1937 assert "60 seconds" in approve_entries[0].reason
1938 assert approve_entries[0].moderator_user_id == moderator.id
1941def test_auto_approve_does_not_approve_recent_items(db):
1942 """Test that auto-approval does not approve items that are newer than the deadline"""
1943 moderator, mod_token = generate_user(is_superuser=True)
1944 user1, token1 = generate_user()
1945 user2, token2 = generate_user()
1947 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1948 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1950 # Create a host request
1951 with requests_session(token1) as api:
1952 with mock_notification_email() as mock:
1953 host_request_id = api.CreateHostRequest(
1954 requests_pb2.CreateHostRequestReq(
1955 host_user_id=user2.id,
1956 from_date=today_plus_2,
1957 to_date=today_plus_3,
1958 text=valid_request_text(),
1959 )
1960 ).host_request_id
1962 # No email sent (shadowed)
1963 mock.assert_not_called()
1965 # Set deadline to 1 hour (items older than 1 hour will be auto-approved)
1966 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 3600
1967 config["MODERATION_BOT_USER_ID"] = moderator.id
1969 # Run the job - the item was just created, so it shouldn't be approved
1970 with mock_notification_email() as mock:
1971 auto_approve_moderation_queue(empty_pb2.Empty())
1973 # Still no email sent
1974 mock.assert_not_called()
1976 # Host still cannot see the request
1977 with requests_session(token2) as api:
1978 with pytest.raises(grpc.RpcError) as e:
1979 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1980 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1982 # Not in host's received list
1983 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
1984 assert len(res.host_requests) == 0
1986 # Moderator sees it still in queue unresolved
1987 with real_moderation_session(mod_token) as api:
1988 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1989 assert len(res.queue_items) == 1
1991 # State is still SHADOWED
1992 state_res = api.GetModerationState(
1993 moderation_pb2.GetModerationStateReq(
1994 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1995 object_id=host_request_id,
1996 )
1997 )
1998 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2001def test_auto_approve_does_not_approve_already_approved(db):
2002 """Test that auto-approval does not re-approve already visible content"""
2003 moderator, mod_token = generate_user(is_superuser=True)
2004 user1, token1 = generate_user()
2005 user2, token2 = generate_user()
2007 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2008 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2010 # Create a host request
2011 with requests_session(token1) as api:
2012 host_request_id = api.CreateHostRequest(
2013 requests_pb2.CreateHostRequestReq(
2014 host_user_id=user2.id,
2015 from_date=today_plus_2,
2016 to_date=today_plus_3,
2017 text=valid_request_text(),
2018 )
2019 ).host_request_id
2021 # Moderator approves it manually
2022 with real_moderation_session(mod_token) as api:
2023 state_res = api.GetModerationState(
2024 moderation_pb2.GetModerationStateReq(
2025 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2026 object_id=host_request_id,
2027 )
2028 )
2029 state_id = state_res.moderation_state.moderation_state_id
2031 api.ModerateContent(
2032 moderation_pb2.ModerateContentReq(
2033 moderation_state_id=state_id,
2034 action=moderation_pb2.MODERATION_ACTION_APPROVE,
2035 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2036 reason="Approved by moderator",
2037 )
2038 )
2040 # Host can now see it
2041 with requests_session(token2) as api:
2042 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2043 assert res.host_request_id == host_request_id
2045 # Get log count before auto-approval
2046 with real_moderation_session(mod_token) as api:
2047 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2048 log_count_before = len(log_res_before.log_entries)
2050 # Set deadline to 1 second
2051 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1
2052 config["MODERATION_BOT_USER_ID"] = moderator.id
2054 # Run the job
2055 auto_approve_moderation_queue(empty_pb2.Empty())
2057 # No new log entries should be created (already approved, queue item resolved)
2058 with real_moderation_session(mod_token) as api:
2059 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2060 assert len(log_res_after.log_entries) == log_count_before
2062 # Queue should be empty (item was resolved when moderator approved)
2063 queue_res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2064 assert len(queue_res.queue_items) == 0
2067def test_auto_approve_does_not_approve_moderator_shadowed_items(db):
2068 """Test that auto-approval does not approve items that were explicitly shadowed by a moderator"""
2069 moderator, mod_token = generate_user(is_superuser=True)
2070 user1, token1 = generate_user()
2071 user2, token2 = generate_user()
2073 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2074 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2076 # Create a host request
2077 with requests_session(token1) as api:
2078 host_request_id = api.CreateHostRequest(
2079 requests_pb2.CreateHostRequestReq(
2080 host_user_id=user2.id,
2081 from_date=today_plus_2,
2082 to_date=today_plus_3,
2083 text=valid_request_text(),
2084 )
2085 ).host_request_id
2087 # Moderator explicitly shadows the content (keeping it shadowed but resolving the queue item)
2088 with real_moderation_session(mod_token) as api:
2089 state_res = api.GetModerationState(
2090 moderation_pb2.GetModerationStateReq(
2091 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2092 object_id=host_request_id,
2093 )
2094 )
2095 state_id = state_res.moderation_state.moderation_state_id
2097 # Set to SHADOWED explicitly - this resolves the INITIAL_REVIEW queue item
2098 api.ModerateContent(
2099 moderation_pb2.ModerateContentReq(
2100 moderation_state_id=state_id,
2101 action=moderation_pb2.MODERATION_ACTION_HIDE,
2102 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2103 reason="Keeping shadowed for review",
2104 )
2105 )
2107 # Backdate to ensure it would be old enough for auto-approval
2108 with session_scope() as session:
2109 queue_item = session.execute(
2110 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
2111 ).scalar_one()
2112 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10)
2114 # Set deadline to 1 second
2115 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1
2116 config["MODERATION_BOT_USER_ID"] = moderator.id
2118 # Get log count before
2119 with real_moderation_session(mod_token) as api:
2120 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2121 log_count_before = len(log_res_before.log_entries)
2123 # Run the job
2124 auto_approve_moderation_queue(empty_pb2.Empty())
2126 # No new log entries - the queue item was resolved when moderator shadowed it
2127 with real_moderation_session(mod_token) as api:
2128 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2129 assert len(log_res_after.log_entries) == log_count_before
2131 # State should still be SHADOWED (not auto-approved to VISIBLE)
2132 state_res = api.GetModerationState(
2133 moderation_pb2.GetModerationStateReq(
2134 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2135 object_id=host_request_id,
2136 )
2137 )
2138 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2140 # Host still cannot see the request
2141 with requests_session(token2) as api:
2142 with pytest.raises(grpc.RpcError) as e:
2143 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2144 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2147# ============================================================================
2148# Notification Suppression Tests
2149# ============================================================================
2152def test_host_request_message_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2153 """
2154 Test that notifications are NOT sent for messages in host requests
2155 that haven't been approved yet.
2156 """
2157 host, host_token = generate_user(complete_profile=True)
2158 surfer, surfer_token = generate_user(complete_profile=True)
2160 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2161 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2163 # Create host request (it starts in SHADOWED state)
2164 with requests_session(surfer_token) as api:
2165 hr_id = api.CreateHostRequest(
2166 requests_pb2.CreateHostRequestReq(
2167 host_user_id=host.id,
2168 from_date=today_plus_2,
2169 to_date=today_plus_3,
2170 text=valid_request_text("Initial request message"),
2171 )
2172 ).host_request_id
2174 # No notifications should have been sent to the host (request is SHADOWED)
2175 assert push_collector.count_for_user(host.id) == 0
2177 # Send additional messages BEFORE approval - should NOT generate notifications
2178 with requests_session(surfer_token) as api:
2179 api.SendHostRequestMessage(
2180 requests_pb2.SendHostRequestMessageReq(
2181 host_request_id=hr_id,
2182 text="Follow-up message 1",
2183 )
2184 )
2185 api.SendHostRequestMessage(
2186 requests_pb2.SendHostRequestMessageReq(
2187 host_request_id=hr_id,
2188 text="Follow-up message 2",
2189 )
2190 )
2192 # Host should STILL have no notifications (messages sent while SHADOWED)
2193 assert push_collector.count_for_user(host.id) == 0
2195 # Now approve the request
2196 with mock_notification_email():
2197 moderator.approve_host_request(hr_id)
2199 # Host should now have 3 notifications (all deferred notifications are delivered on approval):
2200 # 1. host_request:create (the initial request)
2201 # 2. host_request:message (Follow-up message 1)
2202 # 3. host_request:message (Follow-up message 2)
2203 assert push_collector.count_for_user(host.id) == 3
2204 push = push_collector.pop_for_user(host.id, last=False)
2205 assert push.content.title == f"New host request from {surfer.name}"
2208def test_host_request_status_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2209 """
2210 Test that status change notifications (accept/reject/etc.) are NOT sent
2211 for host requests that haven't been approved yet.
2213 Note: In practice, the host can't even SEE the request to accept/reject it
2214 when it's SHADOWED. But if they somehow did, we still shouldn't notify.
2215 """
2216 host, host_token = generate_user(complete_profile=True)
2217 surfer, surfer_token = generate_user(complete_profile=True)
2219 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2220 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2222 # Create host request
2223 with requests_session(surfer_token) as api:
2224 hr_id = api.CreateHostRequest(
2225 requests_pb2.CreateHostRequestReq(
2226 host_user_id=host.id,
2227 from_date=today_plus_2,
2228 to_date=today_plus_3,
2229 text=valid_request_text(),
2230 )
2231 ).host_request_id
2233 # No notifications should have been sent to the host (request is SHADOWED)
2234 assert push_collector.count_for_user(host.id) == 0
2236 # The surfer can cancel their own request even when SHADOWED
2237 # But this should NOT notify the host since the request isn't approved
2238 with requests_session(surfer_token) as api:
2239 api.RespondHostRequest(
2240 requests_pb2.RespondHostRequestReq(
2241 host_request_id=hr_id,
2242 status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED,
2243 text="Actually, never mind",
2244 )
2245 )
2247 # Host should STILL have no notifications (cancel notification suppressed)
2248 assert push_collector.count_for_user(host.id) == 0
2251def test_host_request_notifications_sent_after_approval(db, push_collector: PushCollector, moderator):
2252 """
2253 Test that after a host request is approved, all notifications work normally.
2254 """
2255 host, host_token = generate_user(complete_profile=True)
2256 surfer, surfer_token = generate_user(complete_profile=True)
2258 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2259 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2261 # Create and approve host request
2262 with requests_session(surfer_token) as api:
2263 hr_id = api.CreateHostRequest(
2264 requests_pb2.CreateHostRequestReq(
2265 host_user_id=host.id,
2266 from_date=today_plus_2,
2267 to_date=today_plus_3,
2268 text=valid_request_text(),
2269 )
2270 ).host_request_id
2272 with mock_notification_email():
2273 moderator.approve_host_request(hr_id)
2275 # Host should have received 1 notification (the approval notification)
2276 push_collector.pop_for_user(host.id, last=True)
2278 # Host accepts the request - surfer should be notified
2279 with requests_session(host_token) as api:
2280 with mock_notification_email():
2281 api.RespondHostRequest(
2282 requests_pb2.RespondHostRequestReq(
2283 host_request_id=hr_id,
2284 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
2285 text="Sure, come on over!",
2286 )
2287 )
2289 # Surfer should have 1 notification (the accept notification)
2290 push = push_collector.pop_for_user(surfer.id, last=True)
2291 assert push.content.title == f"{host.name} accepted your host request"
2293 # Surfer confirms - host should be notified
2294 with requests_session(surfer_token) as api:
2295 with mock_notification_email():
2296 api.RespondHostRequest(
2297 requests_pb2.RespondHostRequestReq(
2298 host_request_id=hr_id,
2299 status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED,
2300 text="See you then!",
2301 )
2302 )
2304 # Host should now have received the confirmation notifications
2305 push = push_collector.pop_for_user(host.id, last=True)
2306 assert push.content.title == f"{surfer.name} confirmed their host request"
2309def test_group_chat_message_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2310 """
2311 Test that notifications are NOT sent for messages in group chats
2312 that haven't been approved yet.
2313 """
2314 from couchers.jobs.worker import process_job
2315 from couchers.models import GroupChat
2317 user1, token1 = generate_user(complete_profile=True)
2318 user2, token2 = generate_user(complete_profile=True)
2320 # Create a group chat (starts in SHADOWED state)
2321 with conversations_session(token1) as api:
2322 res = api.CreateGroupChat(
2323 conversations_pb2.CreateGroupChatReq(
2324 recipient_user_ids=[user2.id],
2325 )
2326 )
2327 gc_id = res.group_chat_id
2329 # Verify initial state
2330 with session_scope() as session:
2331 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2332 assert gc.moderation_state.visibility == ModerationVisibility.shadowed
2334 # No notifications should have been sent yet (chat is SHADOWED)
2335 assert push_collector.count_for_user(user2.id) == 0
2337 # Send messages BEFORE approval
2338 with conversations_session(token1) as api:
2339 api.SendMessage(
2340 conversations_pb2.SendMessageReq(
2341 group_chat_id=gc_id,
2342 text="Hello before approval",
2343 )
2344 )
2346 # Process the queued notification job
2347 while process_job():
2348 pass
2350 # User2 should STILL have no notifications (chat is SHADOWED)
2351 assert push_collector.count_for_user(user2.id) == 0
2353 # Now approve the group chat
2354 moderator.approve_group_chat(gc_id)
2356 # Process the queued notification jobs from approval
2357 while process_job():
2358 pass
2360 # Verify moderation state after approval
2361 with session_scope() as session:
2362 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2363 assert gc.moderation_state.visibility == ModerationVisibility.visible
2365 # User2 should have received 1 notification for the first message sent before approval
2366 push = push_collector.pop_for_user(user2.id, last=True)
2367 assert push.content.title == user1.name
2368 assert push.content.body == "Hello before approval"
2370 # Send a message AFTER approval
2371 with conversations_session(token1) as api:
2372 api.SendMessage(
2373 conversations_pb2.SendMessageReq(
2374 group_chat_id=gc_id,
2375 text="Hello after approval",
2376 )
2377 )
2379 # Process the queued notification job
2380 while process_job():
2381 pass
2383 # User2 should have received another notification
2384 assert push_collector.count_for_user(user2.id) == 1
2387def test_event_moderation_state_content(db):
2388 """Test that event moderation state content includes both title and description"""
2389 super_user, super_token = generate_user(is_superuser=True)
2390 user, token = generate_user()
2392 with session_scope() as session:
2393 create_community(session, 0, 2, "Community", [user], [], None)
2395 start_time = now() + timedelta(hours=2)
2396 end_time = start_time + timedelta(hours=3)
2398 with events_session(token) as api:
2399 res = api.CreateEvent(
2400 events_pb2.CreateEventReq(
2401 title="My Event Title",
2402 content="My event description.",
2403 photo_key=None,
2404 offline_information=events_pb2.OfflineEventInformation(
2405 address="Near Null Island",
2406 lat=0.1,
2407 lng=0.2,
2408 ),
2409 start_time=Timestamp_from_datetime(start_time),
2410 end_time=Timestamp_from_datetime(end_time),
2411 timezone="UTC",
2412 )
2413 )
2414 event_id = res.event_id
2416 with session_scope() as session:
2417 occurrence = session.execute(select(EventOccurrence).where(EventOccurrence.id == event_id)).scalar_one()
2418 state_id = occurrence.moderation_state_id
2420 with real_moderation_session(super_token) as api:
2421 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
2422 event_items = [
2423 item
2424 for item in res.queue_items
2425 if item.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_EVENT_OCCURRENCE
2426 ]
2427 assert len(event_items) == 1
2428 assert event_items[0].moderation_state.content == "My Event Title\n\nMy event description."