Coverage for app / backend / src / tests / test_moderation.py: 100%
992 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +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 GroupChat,
17 HostRequest,
18 ModerationAction,
19 ModerationLog,
20 ModerationObjectType,
21 ModerationQueueItem,
22 ModerationState,
23 ModerationTrigger,
24 ModerationVisibility,
25)
26from couchers.moderation.utils import create_moderation
27from couchers.proto import conversations_pb2, moderation_pb2, notifications_pb2, requests_pb2
28from couchers.utils import Timestamp_from_datetime, now, today
29from tests.fixtures.db import generate_user, make_friends
30from tests.fixtures.misc import PushCollector, mock_notification_email, process_jobs
31from tests.fixtures.sessions import (
32 conversations_session,
33 notifications_session,
34 real_moderation_session,
35 requests_session,
36)
37from tests.test_requests import valid_request_text
40@pytest.fixture(autouse=True)
41def _(testconfig):
42 pass
45def create_test_host_request_with_moderation(surfer_token, host_user_id):
46 """Helper to create a host request and return its moderation state ID"""
47 today_plus_2 = (today() + timedelta(days=2)).isoformat()
48 today_plus_3 = (today() + timedelta(days=3)).isoformat()
50 with requests_session(surfer_token) as api:
51 hr_id = api.CreateHostRequest(
52 requests_pb2.CreateHostRequestReq(
53 host_user_id=host_user_id,
54 from_date=today_plus_2,
55 to_date=today_plus_3,
56 text=valid_request_text(),
57 )
58 ).host_request_id
60 with session_scope() as session:
61 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
62 return hr.moderation_state_id
65# ============================================================================
66# Tests for moderation helper functions
67# ============================================================================
70def test_create_moderation(db):
71 """Test creating a moderation state with associated log entry"""
72 user, _ = generate_user()
74 with session_scope() as session:
75 # Create a moderation state
76 moderation_state = create_moderation(
77 session=session,
78 object_type=ModerationObjectType.HOST_REQUEST,
79 object_id=123,
80 creator_user_id=user.id,
81 )
83 assert moderation_state.object_type == ModerationObjectType.HOST_REQUEST
84 assert moderation_state.object_id == 123
85 assert moderation_state.visibility == ModerationVisibility.SHADOWED
87 # Check that log entry was created
88 log_entries = (
89 session.execute(select(ModerationLog).where(ModerationLog.moderation_state_id == moderation_state.id))
90 .scalars()
91 .all()
92 )
94 assert len(log_entries) == 1
95 assert log_entries[0].action == ModerationAction.CREATE
96 assert log_entries[0].reason == "Object created."
97 assert log_entries[0].moderator_user_id == user.id
100def test_add_to_moderation_queue(db):
101 """Test adding content to moderation queue via API"""
102 super_user, super_token = generate_user(is_superuser=True)
103 user1, token1 = generate_user()
104 user2, _ = generate_user()
106 today_plus_2 = (today() + timedelta(days=2)).isoformat()
107 today_plus_3 = (today() + timedelta(days=3)).isoformat()
109 # Create a real host request (which automatically creates moderation state and adds to queue)
110 with requests_session(token1) as api:
111 host_request_id = api.CreateHostRequest(
112 requests_pb2.CreateHostRequestReq(
113 host_user_id=user2.id,
114 from_date=today_plus_2,
115 to_date=today_plus_3,
116 text=valid_request_text(),
117 )
118 ).host_request_id
120 # Get the moderation state ID
121 state_id = None
122 with session_scope() as session:
123 host_request = session.execute(
124 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
125 ).scalar_one()
126 state_id = host_request.moderation_state_id
128 # Add another item to moderation queue via API (the first one was created automatically)
129 with real_moderation_session(super_token) as api:
130 res = api.FlagContentForReview(
131 moderation_pb2.FlagContentForReviewReq(
132 moderation_state_id=state_id,
133 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
134 reason="Admin manually flagged for additional review",
135 )
136 )
138 assert res.queue_item.moderation_state_id == state_id
139 assert res.queue_item.trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG
140 assert res.queue_item.reason == "Admin manually flagged for additional review"
141 assert res.queue_item.moderation_state.author_user_id == user1.id
142 assert res.queue_item.is_resolved == False
145def test_moderate_content(db):
146 """Test moderating content via API"""
147 super_user, super_token = generate_user(is_superuser=True)
148 user, token = generate_user()
149 host, _ = generate_user()
151 today_plus_2 = (today() + timedelta(days=2)).isoformat()
152 today_plus_3 = (today() + timedelta(days=3)).isoformat()
154 # Create a real host request
155 state_id = None
156 with requests_session(token) as api:
157 hr_id = api.CreateHostRequest(
158 requests_pb2.CreateHostRequestReq(
159 host_user_id=host.id,
160 from_date=today_plus_2,
161 to_date=today_plus_3,
162 text=valid_request_text(),
163 )
164 ).host_request_id
166 with session_scope() as session:
167 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
168 state_id = hr.moderation_state_id
170 # Moderate the content via API
171 with real_moderation_session(super_token) as api:
172 res = api.ModerateContent(
173 moderation_pb2.ModerateContentReq(
174 moderation_state_id=state_id,
175 action=moderation_pb2.MODERATION_ACTION_APPROVE,
176 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
177 reason="Content looks good",
178 )
179 )
181 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
183 # Check that state was updated in database
184 with session_scope() as session:
185 updated_state = session.get_one(ModerationState, state_id)
186 assert updated_state.visibility == ModerationVisibility.VISIBLE
188 # Check that log entry was created
189 log_entries = (
190 session.execute(
191 select(ModerationLog)
192 .where(ModerationLog.moderation_state_id == state_id)
193 .order_by(ModerationLog.time.desc(), ModerationLog.id.desc())
194 )
195 .scalars()
196 .all()
197 )
199 assert len(log_entries) == 2 # CREATE + APPROVE
200 assert log_entries[0].action == ModerationAction.APPROVE
201 assert log_entries[0].moderator_user_id == super_user.id
202 assert log_entries[0].reason == "Content looks good"
205def test_resolve_queue_item(db):
206 """Test resolving a moderation queue item via ModerateContent API"""
207 user1, token1 = generate_user()
208 user2, _ = generate_user()
209 moderator, moderator_token = generate_user(is_superuser=True)
211 today_plus_2 = (today() + timedelta(days=2)).isoformat()
212 today_plus_3 = (today() + timedelta(days=3)).isoformat()
214 # Create a host request using the API (which automatically creates moderation state)
215 with requests_session(token1) as api:
216 host_request_id = api.CreateHostRequest(
217 requests_pb2.CreateHostRequestReq(
218 host_user_id=user2.id,
219 from_date=today_plus_2,
220 to_date=today_plus_3,
221 text=valid_request_text(),
222 )
223 ).host_request_id
225 state_id = None
226 with session_scope() as session:
227 # Get the host request and its moderation state
228 host_request = session.execute(
229 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
230 ).scalar_one()
231 state_id = host_request.moderation_state_id
233 # The moderation state should already exist and be in the queue
234 queue_item = session.execute(
235 select(ModerationQueueItem)
236 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
237 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
238 ).scalar_one()
240 assert queue_item.resolved_by_log_id is None
242 # Approve content via API (which should resolve the queue item)
243 with real_moderation_session(moderator_token) as api:
244 api.ModerateContent(
245 moderation_pb2.ModerateContentReq(
246 moderation_state_id=state_id,
247 action=moderation_pb2.MODERATION_ACTION_APPROVE,
248 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
249 reason="Approved after review",
250 )
251 )
253 # Check that queue item was resolved
254 with session_scope() as session:
255 queue_item = session.execute(
256 select(ModerationQueueItem)
257 .where(ModerationQueueItem.moderation_state_id == state_id)
258 .where(ModerationQueueItem.resolved_by_log_id.is_not(None))
259 ).scalar_one()
260 assert queue_item.resolved_by_log_id is not None
263def test_approve_content_via_api(db):
264 """Test approving content via ModerateContent API"""
265 user1, token1 = generate_user()
266 user2, _ = generate_user()
267 moderator, moderator_token = generate_user(is_superuser=True)
269 today_plus_2 = (today() + timedelta(days=2)).isoformat()
270 today_plus_3 = (today() + timedelta(days=3)).isoformat()
272 # Create a host request using the API (which automatically creates moderation state)
273 with requests_session(token1) as api:
274 host_request_id = api.CreateHostRequest(
275 requests_pb2.CreateHostRequestReq(
276 host_user_id=user2.id,
277 from_date=today_plus_2,
278 to_date=today_plus_3,
279 text=valid_request_text(),
280 )
281 ).host_request_id
283 state_id = None
284 with session_scope() as session:
285 # Get the host request and its moderation state
286 host_request = session.execute(
287 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
288 ).scalar_one()
289 state_id = host_request.moderation_state_id
291 # Approve via API
292 with real_moderation_session(moderator_token) as api:
293 api.ModerateContent(
294 moderation_pb2.ModerateContentReq(
295 moderation_state_id=state_id,
296 action=moderation_pb2.MODERATION_ACTION_APPROVE,
297 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
298 reason="Quick approval",
299 )
300 )
302 # Check that state was updated to VISIBLE
303 with session_scope() as session:
304 updated_state = session.get_one(ModerationState, state_id)
305 assert updated_state.visibility == ModerationVisibility.VISIBLE
307 # Check log entry
308 log_entry = session.execute(
309 select(ModerationLog)
310 .where(ModerationLog.moderation_state_id == state_id)
311 .where(ModerationLog.action == ModerationAction.APPROVE)
312 ).scalar_one()
314 assert log_entry.moderator_user_id == moderator.id
315 assert log_entry.reason == "Quick approval"
318# ============================================================================
319# Tests for host request moderation integration
320# ============================================================================
323def test_create_host_request_creates_moderation_state(db):
324 """Test that creating a host request automatically creates a moderation state"""
325 user1, token1 = generate_user()
326 user2, token2 = generate_user()
328 today_plus_2 = (today() + timedelta(days=2)).isoformat()
329 today_plus_3 = (today() + timedelta(days=3)).isoformat()
331 with requests_session(token1) as api:
332 host_request_id = api.CreateHostRequest(
333 requests_pb2.CreateHostRequestReq(
334 host_user_id=user2.id,
335 from_date=today_plus_2,
336 to_date=today_plus_3,
337 text=valid_request_text(),
338 )
339 ).host_request_id
341 with session_scope() as session:
342 # Check that host request has a moderation state
343 host_request = session.execute(
344 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
345 ).scalar_one()
347 # Check moderation state properties
348 moderation_state = session.execute(
349 select(ModerationState).where(ModerationState.id == host_request.moderation_state_id)
350 ).scalar_one()
352 assert moderation_state.object_type == ModerationObjectType.HOST_REQUEST
353 assert moderation_state.object_id == host_request_id
354 assert moderation_state.visibility == ModerationVisibility.SHADOWED
356 # Check that it was added to moderation queue
357 queue_items = (
358 session.execute(
359 select(ModerationQueueItem)
360 .where(ModerationQueueItem.moderation_state_id == moderation_state.id)
361 .where(ModerationQueueItem.resolved_by_log_id == None)
362 )
363 .scalars()
364 .all()
365 )
367 assert len(queue_items) == 1
368 assert queue_items[0].trigger == ModerationTrigger.INITIAL_REVIEW
369 # item_author_user_id is no longer stored in the model, it's dynamically retrieved
372def test_host_request_no_notification_before_approval(db, push_collector: PushCollector):
373 """Test that host requests don't send notifications until approved"""
374 user1, token1 = generate_user()
375 user2, token2 = generate_user()
377 today_plus_2 = (today() + timedelta(days=2)).isoformat()
378 today_plus_3 = (today() + timedelta(days=3)).isoformat()
380 with requests_session(token1) as api:
381 host_request_id = api.CreateHostRequest(
382 requests_pb2.CreateHostRequestReq(
383 host_user_id=user2.id,
384 from_date=today_plus_2,
385 to_date=today_plus_3,
386 text=valid_request_text(),
387 )
388 ).host_request_id
390 # Process all jobs (including the notification job)
391 process_jobs()
393 # No push notification should be sent yet (host requests are shadowed initially)
394 assert push_collector.count_for_user(user2.id) == 0
397def test_shadowed_notification_not_in_list_notifications(db):
398 """Test that notifications for shadowed content don't appear in ListNotifications API"""
399 user1, token1 = generate_user()
400 user2, token2 = generate_user()
402 today_plus_2 = (today() + timedelta(days=2)).isoformat()
403 today_plus_3 = (today() + timedelta(days=3)).isoformat()
405 # Create a host request (which creates a shadowed notification for the host)
406 with requests_session(token1) as api:
407 host_request_id = api.CreateHostRequest(
408 requests_pb2.CreateHostRequestReq(
409 host_user_id=user2.id,
410 from_date=today_plus_2,
411 to_date=today_plus_3,
412 text=valid_request_text(),
413 )
414 ).host_request_id
416 # Host (recipient) should NOT see the notification in ListNotifications - it's for shadowed content
417 with notifications_session(token2) as api:
418 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
419 # Should be empty - the host request is still shadowed
420 assert len(res.notifications) == 0
423def test_notification_visible_after_approval(db):
424 """Test that notifications appear in ListNotifications after content is approved"""
425 user1, token1 = generate_user()
426 user2, token2 = generate_user()
427 mod, mod_token = generate_user(is_superuser=True)
429 today_plus_2 = (today() + timedelta(days=2)).isoformat()
430 today_plus_3 = (today() + timedelta(days=3)).isoformat()
432 # Create a host request (which creates a shadowed notification for the host)
433 with requests_session(token1) as api:
434 host_request_id = api.CreateHostRequest(
435 requests_pb2.CreateHostRequestReq(
436 host_user_id=user2.id,
437 from_date=today_plus_2,
438 to_date=today_plus_3,
439 text=valid_request_text(),
440 )
441 ).host_request_id
443 # Host (recipient) should NOT see the notification initially
444 with notifications_session(token2) as api:
445 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
446 assert len(res.notifications) == 0
448 # Get the moderation state ID and approve
449 with session_scope() as session:
450 host_request = session.execute(
451 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
452 ).scalar_one()
453 state_id = host_request.moderation_state_id
455 with real_moderation_session(mod_token) as api:
456 api.ModerateContent(
457 moderation_pb2.ModerateContentReq(
458 moderation_state_id=state_id,
459 action=moderation_pb2.MODERATION_ACTION_APPROVE,
460 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
461 reason="Looks good",
462 )
463 )
465 # Now host SHOULD see the notification
466 with notifications_session(token2) as api:
467 res = api.ListNotifications(notifications_pb2.ListNotificationsReq())
468 assert len(res.notifications) == 1
469 assert res.notifications[0].topic == "host_request"
470 assert res.notifications[0].action == "create"
473def test_shadowed_host_request_visible_to_author_only(db):
474 """Test that SHADOWED host requests are visible only to the author (surfer), not the recipient (host)"""
475 user1, token1 = generate_user()
476 user2, token2 = generate_user()
478 today_plus_2 = (today() + timedelta(days=2)).isoformat()
479 today_plus_3 = (today() + timedelta(days=3)).isoformat()
481 with requests_session(token1) as api:
482 host_request_id = api.CreateHostRequest(
483 requests_pb2.CreateHostRequestReq(
484 host_user_id=user2.id,
485 from_date=today_plus_2,
486 to_date=today_plus_3,
487 text=valid_request_text(),
488 )
489 ).host_request_id
491 # Surfer (author) can see it with GetHostRequest
492 with requests_session(token1) as api:
493 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
494 assert res.host_request_id == host_request_id
495 assert res.latest_message.text.text == valid_request_text()
497 # Host (recipient) CANNOT see it with GetHostRequest - it's shadowed
498 with requests_session(token2) as api:
499 with pytest.raises(grpc.RpcError) as e:
500 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
501 assert e.value.code() == grpc.StatusCode.NOT_FOUND
504def test_unlisted_host_request_not_in_lists(db):
505 """Test that SHADOWED host requests are visible to author but not to recipient"""
506 user1, token1 = generate_user()
507 user2, token2 = generate_user()
509 today_plus_2 = (today() + timedelta(days=2)).isoformat()
510 today_plus_3 = (today() + timedelta(days=3)).isoformat()
512 with requests_session(token1) as api:
513 host_request_id = api.CreateHostRequest(
514 requests_pb2.CreateHostRequestReq(
515 host_user_id=user2.id,
516 from_date=today_plus_2,
517 to_date=today_plus_3,
518 text=valid_request_text(),
519 )
520 ).host_request_id
522 # Surfer (author) should see it in their sent list even though it's SHADOWED
523 with requests_session(token1) as api:
524 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
525 assert len(res.host_requests) == 1
527 # Host should NOT see it in their received list (still SHADOWED from them)
528 with requests_session(token2) as api:
529 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
530 assert len(res.host_requests) == 0
533def test_approved_host_request_in_lists_and_notifications(db, push_collector: PushCollector):
534 """Test that approved host requests appear in lists and send notifications"""
535 user1, token1 = generate_user()
536 user2, token2 = generate_user()
537 mod, mod_token = generate_user(is_superuser=True)
539 today_plus_2 = (today() + timedelta(days=2)).isoformat()
540 today_plus_3 = (today() + timedelta(days=3)).isoformat()
542 with requests_session(token1) as api:
543 host_request_id = api.CreateHostRequest(
544 requests_pb2.CreateHostRequestReq(
545 host_user_id=user2.id,
546 from_date=today_plus_2,
547 to_date=today_plus_3,
548 text=valid_request_text(),
549 )
550 ).host_request_id
552 # Process the initial notification job - should be deferred (no notification sent)
553 process_jobs()
554 assert push_collector.count_for_user(user2.id) == 0
556 # Get the moderation state ID
557 state_id = None
558 with session_scope() as session:
559 host_request = session.execute(
560 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
561 ).scalar_one()
562 state_id = host_request.moderation_state_id
564 # Approve the host request via API
565 with real_moderation_session(mod_token) as api:
566 api.ModerateContent(
567 moderation_pb2.ModerateContentReq(
568 moderation_state_id=state_id,
569 action=moderation_pb2.MODERATION_ACTION_APPROVE,
570 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
571 reason="Looks good",
572 )
573 )
575 # Process the re-queued notification job - should now send notification
576 process_jobs()
578 # Now surfer SHOULD see it in their sent list
579 with requests_session(token1) as api:
580 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
581 assert len(res.host_requests) == 1
582 assert res.host_requests[0].host_request_id == host_request_id
584 # Host SHOULD see it in their received list
585 with requests_session(token2) as api:
586 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
587 assert len(res.host_requests) == 1
588 assert res.host_requests[0].host_request_id == host_request_id
590 # After approval, the host should have received a push notification
591 assert push_collector.pop_for_user(user2.id, last=True).topic_action == "host_request:create"
594def test_hidden_host_request_invisible_to_all(db):
595 """Test that HIDDEN host requests are invisible to everyone except moderators"""
596 user1, token1 = generate_user()
597 user2, token2 = generate_user()
598 user3, token3 = generate_user() # Third party
599 moderator, moderator_token = generate_user(is_superuser=True)
601 today_plus_2 = (today() + timedelta(days=2)).isoformat()
602 today_plus_3 = (today() + timedelta(days=3)).isoformat()
604 with requests_session(token1) as api:
605 host_request_id = api.CreateHostRequest(
606 requests_pb2.CreateHostRequestReq(
607 host_user_id=user2.id,
608 from_date=today_plus_2,
609 to_date=today_plus_3,
610 text=valid_request_text(),
611 )
612 ).host_request_id
614 # Get the moderation state ID
615 state_id = None
616 with session_scope() as session:
617 host_request = session.execute(
618 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
619 ).scalar_one()
620 state_id = host_request.moderation_state_id
622 # Hide the host request via API (e.g., spam/abuse)
623 with real_moderation_session(moderator_token) as api:
624 api.ModerateContent(
625 moderation_pb2.ModerateContentReq(
626 moderation_state_id=state_id,
627 action=moderation_pb2.MODERATION_ACTION_HIDE,
628 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
629 reason="Spam content",
630 )
631 )
633 # Surfer can't see it with GetHostRequest
634 with requests_session(token1) as api:
635 with pytest.raises(grpc.RpcError) as e:
636 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
637 assert e.value.code() == grpc.StatusCode.NOT_FOUND
639 # Host can't see it with GetHostRequest
640 with requests_session(token2) as api:
641 with pytest.raises(grpc.RpcError) as e:
642 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
643 assert e.value.code() == grpc.StatusCode.NOT_FOUND
645 # Third party definitely can't see it
646 with requests_session(token3) as api:
647 with pytest.raises(grpc.RpcError) as e:
648 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
649 assert e.value.code() == grpc.StatusCode.NOT_FOUND
651 # Not in any lists
652 with requests_session(token1) as api:
653 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
654 assert len(res.host_requests) == 0
656 with requests_session(token2) as api:
657 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
658 assert len(res.host_requests) == 0
661def test_multiple_host_requests_listing_visibility(db):
662 """Test that ListHostRequests correctly filters based on moderation state"""
663 user1, token1 = generate_user()
664 user2, token2 = generate_user()
665 moderator, moderator_token = generate_user(is_superuser=True)
667 today_plus_2 = (today() + timedelta(days=2)).isoformat()
668 today_plus_3 = (today() + timedelta(days=3)).isoformat()
670 # Create 3 host requests
671 host_request_ids = []
672 state_ids = []
673 with requests_session(token1) as api:
674 for i in range(3):
675 hr_id = api.CreateHostRequest(
676 requests_pb2.CreateHostRequestReq(
677 host_user_id=user2.id,
678 from_date=today_plus_2,
679 to_date=today_plus_3,
680 text=valid_request_text(f"Test request {i + 1}"),
681 )
682 ).host_request_id
683 host_request_ids.append(hr_id)
685 # Get state IDs
686 with session_scope() as session:
687 for hr_id in host_request_ids:
688 host_request = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
689 state_ids.append(host_request.moderation_state_id)
691 # Approve the first one via API
692 with real_moderation_session(moderator_token) as api:
693 api.ModerateContent(
694 moderation_pb2.ModerateContentReq(
695 moderation_state_id=state_ids[0],
696 action=moderation_pb2.MODERATION_ACTION_APPROVE,
697 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
698 reason="Approved",
699 )
700 )
702 # Hide the third one via API
703 with real_moderation_session(moderator_token) as api:
704 api.ModerateContent(
705 moderation_pb2.ModerateContentReq(
706 moderation_state_id=state_ids[2],
707 action=moderation_pb2.MODERATION_ACTION_HIDE,
708 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
709 reason="Spam",
710 )
711 )
713 # Surfer should see the approved one and the shadowed one (author can see their SHADOWED content)
714 with requests_session(token1) as api:
715 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
716 assert len(res.host_requests) == 2
717 visible_ids = {hr.host_request_id for hr in res.host_requests}
718 assert visible_ids == {host_request_ids[0], host_request_ids[1]}
720 # Host should see only the approved one in received list
721 with requests_session(token2) as api:
722 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
723 assert len(res.host_requests) == 1
724 assert res.host_requests[0].host_request_id == host_request_ids[0]
727def test_moderation_log_tracking(db):
728 """Test that moderation actions are properly logged via API"""
729 user, user_token = generate_user()
730 host, _ = generate_user()
731 moderator1, moderator1_token = generate_user(is_superuser=True)
732 moderator2, moderator2_token = generate_user(is_superuser=True)
734 # Create a real host request
735 state_id = create_test_host_request_with_moderation(user_token, host.id)
737 # Perform several moderation actions via API
738 with real_moderation_session(moderator1_token) as api:
739 api.ModerateContent(
740 moderation_pb2.ModerateContentReq(
741 moderation_state_id=state_id,
742 action=moderation_pb2.MODERATION_ACTION_APPROVE,
743 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
744 reason="Looks good initially",
745 )
746 )
748 with real_moderation_session(moderator2_token) as api:
749 api.FlagContentForReview(
750 moderation_pb2.FlagContentForReviewReq(
751 moderation_state_id=state_id,
752 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
753 reason="Wait, this needs another look",
754 )
755 )
756 # Shadow it back
757 api.ModerateContent(
758 moderation_pb2.ModerateContentReq(
759 moderation_state_id=state_id,
760 action=moderation_pb2.MODERATION_ACTION_HIDE,
761 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
762 reason="Wait, this needs another look",
763 )
764 )
766 with real_moderation_session(moderator1_token) as api:
767 api.ModerateContent(
768 moderation_pb2.ModerateContentReq(
769 moderation_state_id=state_id,
770 action=moderation_pb2.MODERATION_ACTION_HIDE,
771 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
772 reason="Actually it's spam",
773 )
774 )
776 # Check all log entries
777 with session_scope() as session:
778 log_entries = (
779 session.execute(
780 select(ModerationLog)
781 .where(ModerationLog.moderation_state_id == state_id)
782 .order_by(ModerationLog.time.asc())
783 )
784 .scalars()
785 .all()
786 )
788 # CREATE + APPROVE + HIDE + HIDE (shadowing back counts as HIDE action)
789 assert len(log_entries) >= 3
791 assert log_entries[0].action == ModerationAction.CREATE
792 assert log_entries[0].moderator_user_id == user.id
793 assert log_entries[0].reason == "Object created."
795 assert log_entries[1].action == ModerationAction.APPROVE
796 assert log_entries[1].moderator_user_id == moderator1.id
797 assert log_entries[1].reason == "Looks good initially"
799 # The last action should be hiding
800 assert log_entries[-1].action == ModerationAction.HIDE
801 assert log_entries[-1].moderator_user_id == moderator1.id
802 assert log_entries[-1].reason == "Actually it's spam"
805def test_moderation_queue_workflow(db):
806 """Test the full moderation queue workflow via API"""
807 user1, token1 = generate_user()
808 user2, _ = generate_user()
809 moderator, moderator_token = generate_user(is_superuser=True)
811 today_plus_2 = (today() + timedelta(days=2)).isoformat()
812 today_plus_3 = (today() + timedelta(days=3)).isoformat()
814 # Create a host request using the API (which automatically creates moderation state and adds to queue)
815 with requests_session(token1) as api:
816 host_request_id = api.CreateHostRequest(
817 requests_pb2.CreateHostRequestReq(
818 host_user_id=user2.id,
819 from_date=today_plus_2,
820 to_date=today_plus_3,
821 text=valid_request_text(),
822 )
823 ).host_request_id
825 state_id = None
826 queue_item_id = None
827 with session_scope() as session:
828 # Get the host request and its moderation state
829 host_request = session.execute(
830 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
831 ).scalar_one()
832 state_id = host_request.moderation_state_id
834 # The queue item should already exist (created automatically)
835 queue_item = session.execute(
836 select(ModerationQueueItem)
837 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
838 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
839 ).scalar_one()
840 queue_item_id = queue_item.id
842 # Verify it's in the queue
843 unresolved_items = (
844 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
845 .scalars()
846 .all()
847 )
849 assert len(unresolved_items) >= 1
850 assert queue_item.id in [item.id for item in unresolved_items]
852 # Moderator reviews and approves via API (which also resolves the queue item)
853 with real_moderation_session(moderator_token) as api:
854 api.ModerateContent(
855 moderation_pb2.ModerateContentReq(
856 moderation_state_id=state_id,
857 action=moderation_pb2.MODERATION_ACTION_APPROVE,
858 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
859 reason="Content approved",
860 )
861 )
863 # Verify queue item was resolved
864 with session_scope() as session:
865 # Verify it's no longer in unresolved queue
866 unresolved_items = (
867 session.execute(select(ModerationQueueItem).where(ModerationQueueItem.resolved_by_log_id == None))
868 .scalars()
869 .all()
870 )
872 assert queue_item_id not in [item.id for item in unresolved_items]
874 # Verify the queue item was linked to a log entry
875 queue_item = session.get_one(ModerationQueueItem, queue_item_id)
876 assert queue_item.resolved_by_log_id is not None
879# ============================================================================
880# Moderation API Tests (testing the gRPC servicer)
881# ============================================================================
884def test_GetModerationQueue_empty(db):
885 """Test getting an empty moderation queue"""
886 super_user, super_token = generate_user(is_superuser=True)
888 with real_moderation_session(super_token) as api:
889 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
890 assert len(res.queue_items) == 0
891 assert res.next_page_token == ""
894def test_GetModerationQueue_with_items(db):
895 """Test getting moderation queue with items via API"""
896 super_user, super_token = generate_user(is_superuser=True)
897 normal_user, user_token = generate_user()
898 host, _ = generate_user()
900 # Create some host requests (which automatically adds them to moderation queue)
901 state1_id = create_test_host_request_with_moderation(user_token, host.id)
902 state2_id = create_test_host_request_with_moderation(user_token, host.id)
904 with real_moderation_session(super_token) as api:
905 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
906 assert len(res.queue_items) == 2
907 assert res.queue_items[0].is_resolved == False
908 assert res.queue_items[1].is_resolved == False
911def test_GetModerationQueue_filter_by_trigger(db):
912 """Test filtering moderation queue by trigger type via API"""
913 super_user, super_token = generate_user(is_superuser=True)
914 normal_user, user_token = generate_user()
915 host, _ = generate_user()
917 # Create host requests (which automatically adds them to moderation queue with INITIAL_REVIEW)
918 state1_id = create_test_host_request_with_moderation(user_token, host.id)
919 state2_id = create_test_host_request_with_moderation(user_token, host.id)
921 # Add USER_FLAG trigger to second item via API
922 with real_moderation_session(super_token) as api:
923 api.FlagContentForReview(
924 moderation_pb2.FlagContentForReviewReq(
925 moderation_state_id=state2_id,
926 trigger=moderation_pb2.MODERATION_TRIGGER_USER_FLAG,
927 reason="Reported by user",
928 )
929 )
931 # Filter by INITIAL_REVIEW (should get first item and maybe both depending on how queue works)
932 with real_moderation_session(super_token) as api:
933 res = api.GetModerationQueue(
934 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW])
935 )
936 assert len(res.queue_items) == 2 # Both have INITIAL_REVIEW triggers
937 assert all(item.trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW for item in res.queue_items)
939 # Filter by USER_FLAG (should get second item only)
940 with real_moderation_session(super_token) as api:
941 res = api.GetModerationQueue(
942 moderation_pb2.GetModerationQueueReq(triggers=[moderation_pb2.MODERATION_TRIGGER_USER_FLAG])
943 )
944 assert len(res.queue_items) == 1
945 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_USER_FLAG
948def test_GetModerationQueue_filter_created_before(db):
949 """Test filtering moderation queue by created_before timestamp"""
950 super_user, super_token = generate_user(is_superuser=True)
951 normal_user, user_token = generate_user()
952 host, _ = generate_user()
954 # Create host requests
955 state1_id = create_test_host_request_with_moderation(user_token, host.id)
956 state2_id = create_test_host_request_with_moderation(user_token, host.id)
958 # Backdate the first queue item
959 with session_scope() as session:
960 queue_item1 = session.execute(
961 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
962 ).scalar_one()
963 # Set it to 2 hours ago
964 queue_item1.time_created = now() - timedelta(hours=2)
966 # The second item remains at current time
968 # Filter to items created before 1 hour ago (should only get the first item)
969 cutoff_time = now() - timedelta(hours=1)
970 with real_moderation_session(super_token) as api:
971 res = api.GetModerationQueue(
972 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(cutoff_time))
973 )
974 assert len(res.queue_items) == 1
975 assert res.queue_items[0].moderation_state_id == state1_id
977 # Filter to items created before now (should get both)
978 with real_moderation_session(super_token) as api:
979 res = api.GetModerationQueue(
980 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(now() + timedelta(seconds=10)))
981 )
982 assert len(res.queue_items) == 2
984 # Filter to items created before 3 hours ago (should get none)
985 old_cutoff = now() - timedelta(hours=3)
986 with real_moderation_session(super_token) as api:
987 res = api.GetModerationQueue(
988 moderation_pb2.GetModerationQueueReq(created_before=Timestamp_from_datetime(old_cutoff))
989 )
990 assert len(res.queue_items) == 0
993def test_GetModerationQueue_filter_created_after(db):
994 """Test filtering moderation queue by created_after timestamp"""
995 super_user, super_token = generate_user(is_superuser=True)
996 normal_user, user_token = generate_user()
997 host, _ = generate_user()
999 # Create host requests
1000 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1001 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1003 # Backdate the first queue item to 2 hours ago
1004 with session_scope() as session:
1005 queue_item1 = session.execute(
1006 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1007 ).scalar_one()
1008 queue_item1.time_created = now() - timedelta(hours=2)
1010 # The second item remains at current time
1012 # Filter to items created after 1 hour ago (should only get the second item)
1013 cutoff_time = now() - timedelta(hours=1)
1014 with real_moderation_session(super_token) as api:
1015 res = api.GetModerationQueue(
1016 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(cutoff_time))
1017 )
1018 assert len(res.queue_items) == 1
1019 assert res.queue_items[0].moderation_state_id == state2_id
1021 # Filter to items created after 3 hours ago (should get both)
1022 old_cutoff = now() - timedelta(hours=3)
1023 with real_moderation_session(super_token) as api:
1024 res = api.GetModerationQueue(
1025 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(old_cutoff))
1026 )
1027 assert len(res.queue_items) == 2
1029 # Filter to items created after now (should get none)
1030 with real_moderation_session(super_token) as api:
1031 res = api.GetModerationQueue(
1032 moderation_pb2.GetModerationQueueReq(created_after=Timestamp_from_datetime(now() + timedelta(seconds=10)))
1033 )
1034 assert len(res.queue_items) == 0
1037def test_GetModerationQueue_filter_created_before_and_after(db):
1038 """Test filtering moderation queue by both created_before and created_after timestamps"""
1039 super_user, super_token = generate_user(is_superuser=True)
1040 normal_user, user_token = generate_user()
1041 host, _ = generate_user()
1043 # Create 3 host requests
1044 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1045 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1046 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1048 # Set different times: state1 = 3 hours ago, state2 = 1.5 hours ago, state3 = now
1049 with session_scope() as session:
1050 queue_item1 = session.execute(
1051 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1052 ).scalar_one()
1053 queue_item1.time_created = now() - timedelta(hours=3)
1055 queue_item2 = session.execute(
1056 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1057 ).scalar_one()
1058 queue_item2.time_created = now() - timedelta(hours=1, minutes=30)
1060 # Filter to items between 2 hours ago and 1 hour ago (should only get state2)
1061 after_cutoff = now() - timedelta(hours=2)
1062 before_cutoff = now() - timedelta(hours=1)
1063 with real_moderation_session(super_token) as api:
1064 res = api.GetModerationQueue(
1065 moderation_pb2.GetModerationQueueReq(
1066 created_after=Timestamp_from_datetime(after_cutoff),
1067 created_before=Timestamp_from_datetime(before_cutoff),
1068 )
1069 )
1070 assert len(res.queue_items) == 1
1071 assert res.queue_items[0].moderation_state_id == state2_id
1073 # Filter to items between 4 hours ago and 2.5 hours ago (should only get state1)
1074 after_cutoff = now() - timedelta(hours=4)
1075 before_cutoff = now() - timedelta(hours=2, minutes=30)
1076 with real_moderation_session(super_token) as api:
1077 res = api.GetModerationQueue(
1078 moderation_pb2.GetModerationQueueReq(
1079 created_after=Timestamp_from_datetime(after_cutoff),
1080 created_before=Timestamp_from_datetime(before_cutoff),
1081 )
1082 )
1083 assert len(res.queue_items) == 1
1084 assert res.queue_items[0].moderation_state_id == state1_id
1087def test_GetModerationQueue_filter_unresolved(db):
1088 """Test filtering moderation queue for unresolved items only via API"""
1089 super_user, super_token = generate_user(is_superuser=True)
1090 normal_user, user_token = generate_user()
1091 host, _ = generate_user()
1093 # Create 2 host requests
1094 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1095 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1097 # Resolve the first one via API (ModerateContent automatically resolves queue items)
1098 with real_moderation_session(super_token) as api:
1099 api.ModerateContent(
1100 moderation_pb2.ModerateContentReq(
1101 moderation_state_id=state1_id,
1102 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1103 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1104 reason="Approved",
1105 )
1106 )
1108 # Get all items
1109 with real_moderation_session(super_token) as api:
1110 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1111 assert len(res.queue_items) == 2
1113 # Get only unresolved items
1114 with real_moderation_session(super_token) as api:
1115 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1116 assert len(res.queue_items) == 1
1117 assert res.queue_items[0].is_resolved == False
1120def test_GetModerationQueue_filter_by_author(db):
1121 """Test filtering moderation queue by item_author_user_id"""
1122 super_user, super_token = generate_user(is_superuser=True)
1123 user1, token1 = generate_user()
1124 user2, token2 = generate_user()
1125 host_user, _ = generate_user()
1127 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1128 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1130 # Create 2 host requests by user1
1131 with requests_session(token1) as api:
1132 hr1_id = api.CreateHostRequest(
1133 requests_pb2.CreateHostRequestReq(
1134 host_user_id=host_user.id,
1135 from_date=today_plus_2,
1136 to_date=today_plus_3,
1137 text=valid_request_text(),
1138 )
1139 ).host_request_id
1141 hr2_id = api.CreateHostRequest(
1142 requests_pb2.CreateHostRequestReq(
1143 host_user_id=host_user.id,
1144 from_date=today_plus_2,
1145 to_date=today_plus_3,
1146 text=valid_request_text(),
1147 )
1148 ).host_request_id
1150 # Create 1 host request by user2
1151 with requests_session(token2) as api:
1152 hr3_id = api.CreateHostRequest(
1153 requests_pb2.CreateHostRequestReq(
1154 host_user_id=host_user.id,
1155 from_date=today_plus_2,
1156 to_date=today_plus_3,
1157 text=valid_request_text(),
1158 )
1159 ).host_request_id
1161 # Get moderation state IDs
1162 state1_id, state2_id, state3_id = None, None, None
1163 with session_scope() as session:
1164 hr1 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr1_id)).scalar_one()
1165 hr2 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr2_id)).scalar_one()
1166 hr3 = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr3_id)).scalar_one()
1167 state1_id = hr1.moderation_state_id
1168 state2_id = hr2.moderation_state_id
1169 state3_id = hr3.moderation_state_id
1171 # Get all items (should be 3)
1172 with real_moderation_session(super_token) as api:
1173 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1174 assert len(res.queue_items) == 3
1176 # Filter by user1 (should get 2)
1177 with real_moderation_session(super_token) as api:
1178 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user1.id))
1179 assert len(res.queue_items) == 2
1180 assert all(item.moderation_state.author_user_id == user1.id for item in res.queue_items)
1182 # Filter by user2 (should get 1)
1183 with real_moderation_session(super_token) as api:
1184 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=user2.id))
1185 assert len(res.queue_items) == 1
1186 assert res.queue_items[0].moderation_state.author_user_id == user2.id
1187 assert res.queue_items[0].moderation_state_id == state3_id
1189 # Filter by non-existent user (should get 0)
1190 with real_moderation_session(super_token) as api:
1191 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(item_author_user_id=999999))
1192 assert len(res.queue_items) == 0
1195def test_GetModerationQueue_ordering(db):
1196 """Test ordering moderation queue by oldest/newest first"""
1197 super_user, super_token = generate_user(is_superuser=True)
1198 normal_user, user_token = generate_user()
1199 host, _ = generate_user()
1201 # Create 3 host requests
1202 state1_id = create_test_host_request_with_moderation(user_token, host.id)
1203 state2_id = create_test_host_request_with_moderation(user_token, host.id)
1204 state3_id = create_test_host_request_with_moderation(user_token, host.id)
1206 # Set different times: state1 = 3 hours ago, state2 = 2 hours ago, state3 = 1 hour ago
1207 with session_scope() as session:
1208 queue_item1 = session.execute(
1209 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state1_id)
1210 ).scalar_one()
1211 queue_item1.time_created = now() - timedelta(hours=3)
1213 queue_item2 = session.execute(
1214 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state2_id)
1215 ).scalar_one()
1216 queue_item2.time_created = now() - timedelta(hours=2)
1218 queue_item3 = session.execute(
1219 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state3_id)
1220 ).scalar_one()
1221 queue_item3.time_created = now() - timedelta(hours=1)
1223 # Default order (oldest first)
1224 with real_moderation_session(super_token) as api:
1225 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq())
1226 assert len(res.queue_items) == 3
1227 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1228 assert res.queue_items[1].moderation_state_id == state2_id
1229 assert res.queue_items[2].moderation_state_id == state3_id # newest
1231 # Explicit oldest first
1232 with real_moderation_session(super_token) as api:
1233 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=False))
1234 assert len(res.queue_items) == 3
1235 assert res.queue_items[0].moderation_state_id == state1_id # oldest
1236 assert res.queue_items[2].moderation_state_id == state3_id # newest
1238 # Newest first
1239 with real_moderation_session(super_token) as api:
1240 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(newest_first=True))
1241 assert len(res.queue_items) == 3
1242 assert res.queue_items[0].moderation_state_id == state3_id # newest
1243 assert res.queue_items[1].moderation_state_id == state2_id
1244 assert res.queue_items[2].moderation_state_id == state1_id # oldest
1247def test_GetModerationQueue_pagination_newest_first(db):
1248 """Test pagination with newest_first=True returns different items on each page"""
1249 super_user, super_token = generate_user(is_superuser=True)
1250 normal_user, normal_token = generate_user()
1251 host_user, _ = generate_user()
1253 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1254 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1256 # Create 5 host requests
1257 hr_ids = []
1258 with requests_session(normal_token) as api:
1259 for i in range(5):
1260 hr_id = api.CreateHostRequest(
1261 requests_pb2.CreateHostRequestReq(
1262 host_user_id=host_user.id,
1263 from_date=today_plus_2,
1264 to_date=today_plus_3,
1265 text=valid_request_text(),
1266 )
1267 ).host_request_id
1268 hr_ids.append(hr_id)
1270 # Get moderation state IDs
1271 state_ids = []
1272 with session_scope() as session:
1273 for hr_id in hr_ids:
1274 hr = session.execute(select(HostRequest).where(HostRequest.conversation_id == hr_id)).scalar_one()
1275 state_ids.append(hr.moderation_state_id)
1277 # Set different times so ordering is deterministic
1278 with session_scope() as session:
1279 for i, state_id in enumerate(state_ids):
1280 queue_item = session.execute(
1281 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
1282 ).scalar_one()
1283 queue_item.time_created = now() - timedelta(hours=5 - i) # oldest first in list
1285 # Get first page (2 items) with newest_first=True, filtered to our user's items
1286 with real_moderation_session(super_token) as api:
1287 res1 = api.GetModerationQueue(
1288 moderation_pb2.GetModerationQueueReq(page_size=2, newest_first=True, item_author_user_id=normal_user.id)
1289 )
1290 assert len(res1.queue_items) == 2
1291 # Should get newest items: state_ids[4], state_ids[3]
1292 assert res1.queue_items[0].moderation_state_id == state_ids[4]
1293 assert res1.queue_items[1].moderation_state_id == state_ids[3]
1294 assert res1.next_page_token # should have more pages
1296 # Get second page using the token
1297 res2 = api.GetModerationQueue(
1298 moderation_pb2.GetModerationQueueReq(
1299 page_size=2, newest_first=True, page_token=res1.next_page_token, item_author_user_id=normal_user.id
1300 )
1301 )
1302 assert len(res2.queue_items) == 2
1303 # Should get next newest items: state_ids[2], state_ids[1]
1304 assert res2.queue_items[0].moderation_state_id == state_ids[2]
1305 assert res2.queue_items[1].moderation_state_id == state_ids[1]
1307 # Pages should not overlap
1308 page1_ids = {item.moderation_state_id for item in res1.queue_items}
1309 page2_ids = {item.moderation_state_id for item in res2.queue_items}
1310 assert page1_ids.isdisjoint(page2_ids), "Pages should not have overlapping items"
1313def test_GetModerationLog(db):
1314 """Test getting moderation log for a state via API"""
1315 super_user, super_token = generate_user(is_superuser=True)
1316 moderator, moderator_token = generate_user(is_superuser=True)
1317 normal_user, user_token = generate_user()
1318 host, _ = generate_user()
1320 # Create a real host request
1321 state_id = create_test_host_request_with_moderation(user_token, host.id)
1323 # Perform a moderation action via API
1324 with real_moderation_session(moderator_token) as api:
1325 api.ModerateContent(
1326 moderation_pb2.ModerateContentReq(
1327 moderation_state_id=state_id,
1328 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1329 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1330 reason="Looks good",
1331 )
1332 )
1334 with real_moderation_session(super_token) as api:
1335 res = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
1336 assert len(res.log_entries) == 2 # CREATE + APPROVE
1337 assert res.moderation_state.moderation_state_id == state_id
1338 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1339 # Log entries are in reverse chronological order
1340 assert res.log_entries[0].action == moderation_pb2.MODERATION_ACTION_APPROVE
1341 assert res.log_entries[0].moderator_user_id == moderator.id
1342 assert res.log_entries[0].reason == "Looks good"
1343 assert res.log_entries[1].action == moderation_pb2.MODERATION_ACTION_CREATE
1344 assert res.log_entries[1].moderator_user_id == normal_user.id
1347def test_GetModerationLog_not_found(db):
1348 """Test getting moderation log for non-existent state"""
1349 super_user, super_token = generate_user(is_superuser=True)
1351 with real_moderation_session(super_token) as api:
1352 with pytest.raises(grpc.RpcError) as e:
1353 api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=999999))
1354 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1355 assert e.value.details() == "Moderation state not found."
1358def test_GetModerationState(db):
1359 """Test getting moderation state by object type and ID"""
1360 super_user, super_token = generate_user(is_superuser=True)
1361 user1, token1 = generate_user()
1362 user2, _ = generate_user()
1364 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1365 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1367 with requests_session(token1) as api:
1368 host_request_id = api.CreateHostRequest(
1369 requests_pb2.CreateHostRequestReq(
1370 host_user_id=user2.id,
1371 from_date=today_plus_2,
1372 to_date=today_plus_3,
1373 text=valid_request_text(),
1374 )
1375 ).host_request_id
1377 with real_moderation_session(super_token) as api:
1378 res = api.GetModerationState(
1379 moderation_pb2.GetModerationStateReq(
1380 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1381 object_id=host_request_id,
1382 )
1383 )
1384 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST
1385 assert res.moderation_state.object_id == host_request_id
1386 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1387 assert res.moderation_state.moderation_state_id > 0
1390def test_GetModerationState_not_found(db):
1391 """Test getting moderation state for non-existent object"""
1392 super_user, super_token = generate_user(is_superuser=True)
1394 with real_moderation_session(super_token) as api:
1395 with pytest.raises(grpc.RpcError) as e:
1396 api.GetModerationState(
1397 moderation_pb2.GetModerationStateReq(
1398 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1399 object_id=999999,
1400 )
1401 )
1402 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1403 assert e.value.details() == "Moderation state not found."
1406def test_GetModerationState_unspecified_type(db):
1407 """Test getting moderation state with unspecified object type"""
1408 super_user, super_token = generate_user(is_superuser=True)
1410 with real_moderation_session(super_token) as api:
1411 with pytest.raises(grpc.RpcError) as e:
1412 api.GetModerationState(
1413 moderation_pb2.GetModerationStateReq(
1414 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_UNSPECIFIED,
1415 object_id=123,
1416 )
1417 )
1418 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
1419 assert e.value.details() == "Object type must be specified."
1422def test_ModerateContent_approve(db):
1423 """Test approving content via unified moderation API"""
1424 super_user, super_token = generate_user(is_superuser=True)
1425 user1, token1 = generate_user()
1426 user2, _ = generate_user()
1428 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1429 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1431 # Create a host request using the API (which automatically creates moderation state)
1432 with requests_session(token1) as api:
1433 host_request_id = api.CreateHostRequest(
1434 requests_pb2.CreateHostRequestReq(
1435 host_user_id=user2.id,
1436 from_date=today_plus_2,
1437 to_date=today_plus_3,
1438 text=valid_request_text(),
1439 )
1440 ).host_request_id
1442 # Get the moderation state ID
1443 state_id = None
1444 with session_scope() as session:
1445 host_request = session.execute(
1446 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1447 ).scalar_one()
1448 state_id = host_request.moderation_state_id
1450 with real_moderation_session(super_token) as api:
1451 res = api.ModerateContent(
1452 moderation_pb2.ModerateContentReq(
1453 moderation_state_id=state_id,
1454 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1455 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1456 reason="Approved by admin",
1457 )
1458 )
1459 assert res.moderation_state.moderation_state_id == state_id
1460 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1462 # Verify state was updated in database
1463 with session_scope() as session:
1464 state = session.get_one(ModerationState, state_id)
1465 assert state.visibility == ModerationVisibility.VISIBLE
1468def test_ModerateContent_not_found(db):
1469 """Test moderating non-existent content"""
1470 super_user, super_token = generate_user(is_superuser=True)
1472 with real_moderation_session(super_token) as api:
1473 with pytest.raises(grpc.RpcError) as e:
1474 api.ModerateContent(
1475 moderation_pb2.ModerateContentReq(
1476 moderation_state_id=999999,
1477 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1478 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1479 reason="Test",
1480 )
1481 )
1482 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1483 assert e.value.details() == "Moderation state not found."
1486def test_ModerateContent_hide(db):
1487 """Test hiding content via unified moderation API"""
1488 super_user, super_token = generate_user(is_superuser=True)
1489 normal_user, user_token = generate_user()
1490 host, _ = generate_user()
1492 # Create a real host request
1493 state_id = create_test_host_request_with_moderation(user_token, host.id)
1495 with real_moderation_session(super_token) as api:
1496 res = api.ModerateContent(
1497 moderation_pb2.ModerateContentReq(
1498 moderation_state_id=state_id,
1499 action=moderation_pb2.MODERATION_ACTION_HIDE,
1500 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1501 reason="Spam content",
1502 )
1503 )
1504 assert res.moderation_state.moderation_state_id == state_id
1505 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_HIDDEN
1507 # Verify state was updated in database
1508 with session_scope() as session:
1509 state = session.get_one(ModerationState, state_id)
1510 assert state.visibility == ModerationVisibility.HIDDEN
1513def test_ModerateContent_shadow(db):
1514 """Test shadowing content via unified moderation API"""
1515 super_user, super_token = generate_user(is_superuser=True)
1516 normal_user, user_token = generate_user()
1517 host, _ = generate_user()
1519 # Create a real host request
1520 state_id = create_test_host_request_with_moderation(user_token, host.id)
1522 with real_moderation_session(super_token) as api:
1523 res = api.ModerateContent(
1524 moderation_pb2.ModerateContentReq(
1525 moderation_state_id=state_id,
1526 action=moderation_pb2.MODERATION_ACTION_HIDE,
1527 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
1528 reason="Needs further review",
1529 )
1530 )
1531 assert res.moderation_state.moderation_state_id == state_id
1532 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1534 # Verify state was updated in database
1535 with session_scope() as session:
1536 state = session.get_one(ModerationState, state_id)
1537 assert state.visibility == ModerationVisibility.SHADOWED
1540def test_FlagContentForReview(db):
1541 """Test flagging content for review via admin API"""
1542 super_user, super_token = generate_user(is_superuser=True)
1543 user1, token1 = generate_user()
1544 user2, _ = generate_user()
1546 # Create a host request
1547 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1548 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1550 with requests_session(token1) as api:
1551 host_request_id = api.CreateHostRequest(
1552 requests_pb2.CreateHostRequestReq(
1553 host_user_id=user2.id,
1554 from_date=today_plus_2,
1555 to_date=today_plus_3,
1556 text=valid_request_text(),
1557 )
1558 ).host_request_id
1560 # Get the moderation state ID
1561 with session_scope() as session:
1562 host_request = session.execute(
1563 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1564 ).scalar_one()
1565 state_id = host_request.moderation_state_id
1567 with real_moderation_session(super_token) as api:
1568 res = api.FlagContentForReview(
1569 moderation_pb2.FlagContentForReviewReq(
1570 moderation_state_id=state_id,
1571 trigger=moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW,
1572 reason="Admin flagged for additional review",
1573 )
1574 )
1575 assert res.queue_item.moderation_state_id == state_id
1576 assert res.queue_item.trigger == moderation_pb2.MODERATION_TRIGGER_MODERATOR_REVIEW
1577 assert res.queue_item.is_resolved == False
1579 # Verify queue item was created in database
1580 with session_scope() as session:
1581 # Get the most recent queue item (the one we just created)
1582 queue_item = (
1583 session.execute(
1584 select(ModerationQueueItem)
1585 .where(ModerationQueueItem.moderation_state_id == state_id)
1586 .order_by(ModerationQueueItem.time_created.desc())
1587 )
1588 .scalars()
1589 .first()
1590 )
1591 assert queue_item
1592 assert queue_item.trigger == ModerationTrigger.MODERATOR_REVIEW
1593 assert queue_item.resolved_by_log_id is None
1596# ============================================================================
1597# Tests for group chat moderation
1598# ============================================================================
1601def test_group_chat_created_with_moderation_state(db):
1602 """Test that group chats are created with moderation state"""
1603 user1, token1 = generate_user()
1604 user2, _ = generate_user()
1605 make_friends(user1, user2)
1607 with conversations_session(token1) as api:
1608 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1609 group_chat_id = res.group_chat_id
1611 # Verify moderation state was created
1612 with session_scope() as session:
1613 group_chat = session.execute(select(GroupChat).where(GroupChat.conversation_id == group_chat_id)).scalar_one()
1615 assert group_chat.moderation_state.object_type == ModerationObjectType.GROUP_CHAT
1616 assert group_chat.moderation_state.object_id == group_chat_id
1617 # Group chats start as SHADOWED
1618 assert group_chat.moderation_state.visibility == ModerationVisibility.SHADOWED
1620 # A moderation queue item should have been created
1621 queue_item = (
1622 session.execute(
1623 select(ModerationQueueItem).where(
1624 ModerationQueueItem.moderation_state_id == group_chat.moderation_state_id
1625 )
1626 )
1627 .scalars()
1628 .first()
1629 )
1630 assert queue_item is not None
1631 assert queue_item.trigger == ModerationTrigger.INITIAL_REVIEW
1634def test_group_chat_GetModerationState(db):
1635 """Test GetModerationState API for group chats"""
1636 user1, token1 = generate_user()
1637 user2, _ = generate_user()
1638 moderator, mod_token = generate_user(is_superuser=True)
1639 make_friends(user1, user2)
1641 with conversations_session(token1) as api:
1642 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1643 group_chat_id = res.group_chat_id
1645 # Moderator can look up the moderation state
1646 with real_moderation_session(mod_token) as api:
1647 res = api.GetModerationState(
1648 moderation_pb2.GetModerationStateReq(
1649 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1650 object_id=group_chat_id,
1651 )
1652 )
1653 assert res.moderation_state.object_type == moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT
1654 assert res.moderation_state.object_id == group_chat_id
1655 # Starts as SHADOWED
1656 assert res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1659def test_group_chat_moderation_hide(db):
1660 """Test that a moderator can hide a group chat and participants can no longer see it"""
1661 user1, token1 = generate_user()
1662 user2, token2 = generate_user()
1663 moderator, mod_token = generate_user(is_superuser=True)
1664 make_friends(user1, user2)
1666 with conversations_session(token1) as api:
1667 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1668 group_chat_id = res.group_chat_id
1669 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
1671 # First approve the group chat so both users can see it
1672 with real_moderation_session(mod_token) as api:
1673 state_res = api.GetModerationState(
1674 moderation_pb2.GetModerationStateReq(
1675 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1676 object_id=group_chat_id,
1677 )
1678 )
1679 api.ModerateContent(
1680 moderation_pb2.ModerateContentReq(
1681 moderation_state_id=state_res.moderation_state.moderation_state_id,
1682 action=moderation_pb2.MODERATION_ACTION_APPROVE,
1683 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
1684 reason="Approved",
1685 )
1686 )
1688 # Both users can see the chat now
1689 with conversations_session(token1) as api:
1690 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1691 assert len(res.group_chats) == 1
1693 with conversations_session(token2) as api:
1694 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1695 assert len(res.group_chats) == 1
1697 # Moderator hides the group chat
1698 with real_moderation_session(mod_token) as api:
1699 state_res = api.GetModerationState(
1700 moderation_pb2.GetModerationStateReq(
1701 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1702 object_id=group_chat_id,
1703 )
1704 )
1705 api.ModerateContent(
1706 moderation_pb2.ModerateContentReq(
1707 moderation_state_id=state_res.moderation_state.moderation_state_id,
1708 action=moderation_pb2.MODERATION_ACTION_HIDE,
1709 visibility=moderation_pb2.MODERATION_VISIBILITY_HIDDEN,
1710 reason="Inappropriate content",
1711 )
1712 )
1714 # Neither user can see the chat now
1715 with conversations_session(token1) as api:
1716 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1717 assert len(res.group_chats) == 0
1719 with conversations_session(token2) as api:
1720 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1721 assert len(res.group_chats) == 0
1723 # Trying to get messages returns empty (chat is hidden so no messages visible)
1724 with conversations_session(token1) as api:
1725 res = api.GetGroupChatMessages(conversations_pb2.GetGroupChatMessagesReq(group_chat_id=group_chat_id))
1726 assert len(res.messages) == 0
1729def test_group_chat_moderation_shadow(db):
1730 """Test that shadowing a group chat hides it from non-creator participants"""
1731 user1, token1 = generate_user() # Creator
1732 user2, token2 = generate_user() # Participant
1733 moderator, mod_token = generate_user(is_superuser=True)
1734 make_friends(user1, user2)
1736 with conversations_session(token1) as api:
1737 res = api.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user2.id]))
1738 group_chat_id = res.group_chat_id
1739 api.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text="Hello!"))
1741 # Moderator shadows the group chat
1742 with real_moderation_session(mod_token) as api:
1743 state_res = api.GetModerationState(
1744 moderation_pb2.GetModerationStateReq(
1745 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_GROUP_CHAT,
1746 object_id=group_chat_id,
1747 )
1748 )
1749 api.ModerateContent(
1750 moderation_pb2.ModerateContentReq(
1751 moderation_state_id=state_res.moderation_state.moderation_state_id,
1752 action=moderation_pb2.MODERATION_ACTION_HIDE,
1753 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
1754 reason="Needs review",
1755 )
1756 )
1758 # Creator can see SHADOWED content in list operations
1759 with conversations_session(token1) as api:
1760 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1761 assert len(res.group_chats) == 1
1762 assert res.group_chats[0].group_chat_id == group_chat_id
1764 # But non-creator participant cannot see it in lists
1765 with conversations_session(token2) as api:
1766 res = api.ListGroupChats(conversations_pb2.ListGroupChatsReq())
1767 assert len(res.group_chats) == 0
1769 # Creator can also access it directly via GetGroupChat
1770 with conversations_session(token1) as api:
1771 res = api.GetGroupChat(conversations_pb2.GetGroupChatReq(group_chat_id=group_chat_id))
1772 assert res.group_chat_id == group_chat_id
1775# ============================================================================
1776# Tests for auto-approval background job
1777# ============================================================================
1780def test_auto_approve_moderation_queue_disabled_when_zero(db):
1781 """Test that auto-approval is disabled when deadline is 0"""
1782 moderator, mod_token = generate_user(is_superuser=True)
1783 user1, token1 = generate_user()
1784 user2, token2 = generate_user()
1786 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1787 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1789 # Create a host request
1790 with requests_session(token1) as api:
1791 with mock_notification_email() as mock:
1792 host_request_id = api.CreateHostRequest(
1793 requests_pb2.CreateHostRequestReq(
1794 host_user_id=user2.id,
1795 from_date=today_plus_2,
1796 to_date=today_plus_3,
1797 text=valid_request_text(),
1798 )
1799 ).host_request_id
1801 # No email should have been sent (request is shadowed)
1802 mock.assert_not_called()
1804 # Ensure deadline is 0 (disabled)
1805 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 0
1807 # Run the job
1808 auto_approve_moderation_queue(empty_pb2.Empty())
1810 # Surfer (author) can see the request via API
1811 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1812 assert res.host_request_id == host_request_id
1814 # Author can see their SHADOWED request in their sent list
1815 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
1816 assert len(res.host_requests) == 1
1817 assert res.host_requests[0].host_request_id == host_request_id
1819 # Host cannot see the request (it's shadowed from them)
1820 with requests_session(token2) as api:
1821 with pytest.raises(grpc.RpcError) as e:
1822 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1823 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1825 # Host doesn't see it in their received list either
1826 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
1827 assert len(res.host_requests) == 0
1829 # Moderator can still see the item in the moderation queue
1830 with real_moderation_session(mod_token) as api:
1831 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1832 assert len(res.queue_items) == 1
1833 assert res.queue_items[0].trigger == moderation_pb2.MODERATION_TRIGGER_INITIAL_REVIEW
1835 # Moderator can check the state is still SHADOWED
1836 state_res = api.GetModerationState(
1837 moderation_pb2.GetModerationStateReq(
1838 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1839 object_id=host_request_id,
1840 )
1841 )
1842 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1845def test_auto_approve_moderation_queue_approves_old_items(db, push_collector: PushCollector):
1846 """Test that auto-approval approves items older than the deadline"""
1847 moderator, mod_token = generate_user(is_superuser=True)
1848 user1, token1 = generate_user()
1849 user2, token2 = generate_user()
1851 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1852 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1854 # Create a host request
1855 with requests_session(token1) as api:
1856 with mock_notification_email() as mock:
1857 host_request_id = api.CreateHostRequest(
1858 requests_pb2.CreateHostRequestReq(
1859 host_user_id=user2.id,
1860 from_date=today_plus_2,
1861 to_date=today_plus_3,
1862 text=valid_request_text("Test request for auto-approval"),
1863 )
1864 ).host_request_id
1866 # No email sent initially (shadowed)
1867 mock.assert_not_called()
1869 # Host cannot see the request yet
1870 with requests_session(token2) as api:
1871 with pytest.raises(grpc.RpcError) as e:
1872 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1873 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1875 # Make the queue item appear old by backdating its time_created
1876 with session_scope() as session:
1877 host_request = session.execute(
1878 select(HostRequest).where(HostRequest.conversation_id == host_request_id)
1879 ).scalar_one()
1880 queue_item = session.execute(
1881 select(ModerationQueueItem)
1882 .where(ModerationQueueItem.moderation_state_id == host_request.moderation_state_id)
1883 .where(ModerationQueueItem.resolved_by_log_id.is_(None))
1884 ).scalar_one()
1885 # Backdate the queue item by 2 minutes
1886 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=2)
1888 # Set deadline to 60 seconds (items older than 60 seconds will be auto-approved)
1889 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 60
1890 config["MODERATION_BOT_USER_ID"] = moderator.id
1892 # Run the job
1893 auto_approve_moderation_queue(empty_pb2.Empty())
1895 # Now host can see the request via API
1896 with requests_session(token2) as api:
1897 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1898 assert res.host_request_id == host_request_id
1899 assert res.latest_message.text.text == valid_request_text("Test request for auto-approval")
1901 # Host sees it in their received list
1902 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
1903 assert len(res.host_requests) == 1
1904 assert res.host_requests[0].host_request_id == host_request_id
1906 # Surfer sees it in their sent list
1907 with requests_session(token1) as api:
1908 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_sent=True))
1909 assert len(res.host_requests) == 1
1910 assert res.host_requests[0].host_request_id == host_request_id
1912 # Moderator sees the queue item is now resolved
1913 with real_moderation_session(mod_token) as api:
1914 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1915 assert len(res.queue_items) == 0
1917 # State is now VISIBLE
1918 state_res = api.GetModerationState(
1919 moderation_pb2.GetModerationStateReq(
1920 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1921 object_id=host_request_id,
1922 )
1923 )
1924 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_VISIBLE
1926 # Check the log shows auto-approval by the bot user
1927 log_res = api.GetModerationLog(
1928 moderation_pb2.GetModerationLogReq(moderation_state_id=state_res.moderation_state.moderation_state_id)
1929 )
1930 # Find the APPROVE action
1931 approve_entries = [e for e in log_res.log_entries if e.action == moderation_pb2.MODERATION_ACTION_APPROVE]
1932 assert len(approve_entries) == 1
1933 assert "Auto-approved" in approve_entries[0].reason
1934 assert "60 seconds" in approve_entries[0].reason
1935 assert approve_entries[0].moderator_user_id == moderator.id
1938def test_auto_approve_does_not_approve_recent_items(db):
1939 """Test that auto-approval does not approve items that are newer than the deadline"""
1940 moderator, mod_token = generate_user(is_superuser=True)
1941 user1, token1 = generate_user()
1942 user2, token2 = generate_user()
1944 today_plus_2 = (today() + timedelta(days=2)).isoformat()
1945 today_plus_3 = (today() + timedelta(days=3)).isoformat()
1947 # Create a host request
1948 with requests_session(token1) as api:
1949 with mock_notification_email() as mock:
1950 host_request_id = api.CreateHostRequest(
1951 requests_pb2.CreateHostRequestReq(
1952 host_user_id=user2.id,
1953 from_date=today_plus_2,
1954 to_date=today_plus_3,
1955 text=valid_request_text(),
1956 )
1957 ).host_request_id
1959 # No email sent (shadowed)
1960 mock.assert_not_called()
1962 # Set deadline to 1 hour (items older than 1 hour will be auto-approved)
1963 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 3600
1964 config["MODERATION_BOT_USER_ID"] = moderator.id
1966 # Run the job - the item was just created, so it shouldn't be approved
1967 with mock_notification_email() as mock:
1968 auto_approve_moderation_queue(empty_pb2.Empty())
1970 # Still no email sent
1971 mock.assert_not_called()
1973 # Host still cannot see the request
1974 with requests_session(token2) as api:
1975 with pytest.raises(grpc.RpcError) as e:
1976 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
1977 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1979 # Not in host's received list
1980 res = api.ListHostRequests(requests_pb2.ListHostRequestsReq(only_received=True))
1981 assert len(res.host_requests) == 0
1983 # Moderator sees it still in queue unresolved
1984 with real_moderation_session(mod_token) as api:
1985 res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
1986 assert len(res.queue_items) == 1
1988 # State is still SHADOWED
1989 state_res = api.GetModerationState(
1990 moderation_pb2.GetModerationStateReq(
1991 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
1992 object_id=host_request_id,
1993 )
1994 )
1995 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
1998def test_auto_approve_does_not_approve_already_approved(db):
1999 """Test that auto-approval does not re-approve already visible content"""
2000 moderator, mod_token = generate_user(is_superuser=True)
2001 user1, token1 = generate_user()
2002 user2, token2 = generate_user()
2004 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2005 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2007 # Create a host request
2008 with requests_session(token1) as api:
2009 host_request_id = api.CreateHostRequest(
2010 requests_pb2.CreateHostRequestReq(
2011 host_user_id=user2.id,
2012 from_date=today_plus_2,
2013 to_date=today_plus_3,
2014 text=valid_request_text(),
2015 )
2016 ).host_request_id
2018 # Moderator approves it manually
2019 with real_moderation_session(mod_token) as api:
2020 state_res = api.GetModerationState(
2021 moderation_pb2.GetModerationStateReq(
2022 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2023 object_id=host_request_id,
2024 )
2025 )
2026 state_id = state_res.moderation_state.moderation_state_id
2028 api.ModerateContent(
2029 moderation_pb2.ModerateContentReq(
2030 moderation_state_id=state_id,
2031 action=moderation_pb2.MODERATION_ACTION_APPROVE,
2032 visibility=moderation_pb2.MODERATION_VISIBILITY_VISIBLE,
2033 reason="Approved by moderator",
2034 )
2035 )
2037 # Host can now see it
2038 with requests_session(token2) as api:
2039 res = api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2040 assert res.host_request_id == host_request_id
2042 # Get log count before auto-approval
2043 with real_moderation_session(mod_token) as api:
2044 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2045 log_count_before = len(log_res_before.log_entries)
2047 # Set deadline to 1 second
2048 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1
2049 config["MODERATION_BOT_USER_ID"] = moderator.id
2051 # Run the job
2052 auto_approve_moderation_queue(empty_pb2.Empty())
2054 # No new log entries should be created (already approved, queue item resolved)
2055 with real_moderation_session(mod_token) as api:
2056 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2057 assert len(log_res_after.log_entries) == log_count_before
2059 # Queue should be empty (item was resolved when moderator approved)
2060 queue_res = api.GetModerationQueue(moderation_pb2.GetModerationQueueReq(unresolved_only=True))
2061 assert len(queue_res.queue_items) == 0
2064def test_auto_approve_does_not_approve_moderator_shadowed_items(db):
2065 """Test that auto-approval does not approve items that were explicitly shadowed by a moderator"""
2066 moderator, mod_token = generate_user(is_superuser=True)
2067 user1, token1 = generate_user()
2068 user2, token2 = generate_user()
2070 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2071 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2073 # Create a host request
2074 with requests_session(token1) as api:
2075 host_request_id = api.CreateHostRequest(
2076 requests_pb2.CreateHostRequestReq(
2077 host_user_id=user2.id,
2078 from_date=today_plus_2,
2079 to_date=today_plus_3,
2080 text=valid_request_text(),
2081 )
2082 ).host_request_id
2084 # Moderator explicitly shadows the content (keeping it shadowed but resolving the queue item)
2085 with real_moderation_session(mod_token) as api:
2086 state_res = api.GetModerationState(
2087 moderation_pb2.GetModerationStateReq(
2088 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2089 object_id=host_request_id,
2090 )
2091 )
2092 state_id = state_res.moderation_state.moderation_state_id
2094 # Set to SHADOWED explicitly - this resolves the INITIAL_REVIEW queue item
2095 api.ModerateContent(
2096 moderation_pb2.ModerateContentReq(
2097 moderation_state_id=state_id,
2098 action=moderation_pb2.MODERATION_ACTION_HIDE,
2099 visibility=moderation_pb2.MODERATION_VISIBILITY_SHADOWED,
2100 reason="Keeping shadowed for review",
2101 )
2102 )
2104 # Backdate to ensure it would be old enough for auto-approval
2105 with session_scope() as session:
2106 queue_item = session.execute(
2107 select(ModerationQueueItem).where(ModerationQueueItem.moderation_state_id == state_id)
2108 ).scalar_one()
2109 queue_item.time_created = datetime.now(queue_item.time_created.tzinfo) - timedelta(minutes=10)
2111 # Set deadline to 1 second
2112 config["MODERATION_AUTO_APPROVE_DEADLINE_SECONDS"] = 1
2113 config["MODERATION_BOT_USER_ID"] = moderator.id
2115 # Get log count before
2116 with real_moderation_session(mod_token) as api:
2117 log_res_before = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2118 log_count_before = len(log_res_before.log_entries)
2120 # Run the job
2121 auto_approve_moderation_queue(empty_pb2.Empty())
2123 # No new log entries - the queue item was resolved when moderator shadowed it
2124 with real_moderation_session(mod_token) as api:
2125 log_res_after = api.GetModerationLog(moderation_pb2.GetModerationLogReq(moderation_state_id=state_id))
2126 assert len(log_res_after.log_entries) == log_count_before
2128 # State should still be SHADOWED (not auto-approved to VISIBLE)
2129 state_res = api.GetModerationState(
2130 moderation_pb2.GetModerationStateReq(
2131 object_type=moderation_pb2.MODERATION_OBJECT_TYPE_HOST_REQUEST,
2132 object_id=host_request_id,
2133 )
2134 )
2135 assert state_res.moderation_state.visibility == moderation_pb2.MODERATION_VISIBILITY_SHADOWED
2137 # Host still cannot see the request
2138 with requests_session(token2) as api:
2139 with pytest.raises(grpc.RpcError) as e:
2140 api.GetHostRequest(requests_pb2.GetHostRequestReq(host_request_id=host_request_id))
2141 assert e.value.code() == grpc.StatusCode.NOT_FOUND
2144# ============================================================================
2145# Notification Suppression Tests
2146# ============================================================================
2149def test_host_request_message_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2150 """
2151 Test that notifications are NOT sent for messages in host requests
2152 that haven't been approved yet.
2153 """
2154 host, host_token = generate_user(complete_profile=True)
2155 surfer, surfer_token = generate_user(complete_profile=True)
2157 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2158 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2160 # Create host request (it starts in SHADOWED state)
2161 with requests_session(surfer_token) as api:
2162 hr_id = api.CreateHostRequest(
2163 requests_pb2.CreateHostRequestReq(
2164 host_user_id=host.id,
2165 from_date=today_plus_2,
2166 to_date=today_plus_3,
2167 text=valid_request_text("Initial request message"),
2168 )
2169 ).host_request_id
2171 # No notifications should have been sent to the host (request is SHADOWED)
2172 assert push_collector.count_for_user(host.id) == 0
2174 # Send additional messages BEFORE approval - should NOT generate notifications
2175 with requests_session(surfer_token) as api:
2176 api.SendHostRequestMessage(
2177 requests_pb2.SendHostRequestMessageReq(
2178 host_request_id=hr_id,
2179 text="Follow-up message 1",
2180 )
2181 )
2182 api.SendHostRequestMessage(
2183 requests_pb2.SendHostRequestMessageReq(
2184 host_request_id=hr_id,
2185 text="Follow-up message 2",
2186 )
2187 )
2189 # Host should STILL have no notifications (messages sent while SHADOWED)
2190 assert push_collector.count_for_user(host.id) == 0
2192 # Now approve the request
2193 with mock_notification_email():
2194 moderator.approve_host_request(hr_id)
2196 # Host should now have 3 notifications (all deferred notifications are delivered on approval):
2197 # 1. host_request:create (the initial request)
2198 # 2. host_request:message (Follow-up message 1)
2199 # 3. host_request:message (Follow-up message 2)
2200 assert push_collector.count_for_user(host.id) == 3
2201 push = push_collector.pop_for_user(host.id, last=False)
2202 assert push.content.title == f"New host request from {surfer.name}"
2205def test_host_request_status_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2206 """
2207 Test that status change notifications (accept/reject/etc.) are NOT sent
2208 for host requests that haven't been approved yet.
2210 Note: In practice, the host can't even SEE the request to accept/reject it
2211 when it's SHADOWED. But if they somehow did, we still shouldn't notify.
2212 """
2213 host, host_token = generate_user(complete_profile=True)
2214 surfer, surfer_token = generate_user(complete_profile=True)
2216 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2217 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2219 # Create host request
2220 with requests_session(surfer_token) as api:
2221 hr_id = api.CreateHostRequest(
2222 requests_pb2.CreateHostRequestReq(
2223 host_user_id=host.id,
2224 from_date=today_plus_2,
2225 to_date=today_plus_3,
2226 text=valid_request_text(),
2227 )
2228 ).host_request_id
2230 # No notifications should have been sent to the host (request is SHADOWED)
2231 assert push_collector.count_for_user(host.id) == 0
2233 # The surfer can cancel their own request even when SHADOWED
2234 # But this should NOT notify the host since the request isn't approved
2235 with requests_session(surfer_token) as api:
2236 api.RespondHostRequest(
2237 requests_pb2.RespondHostRequestReq(
2238 host_request_id=hr_id,
2239 status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED,
2240 text="Actually, never mind",
2241 )
2242 )
2244 # Host should STILL have no notifications (cancel notification suppressed)
2245 assert push_collector.count_for_user(host.id) == 0
2248def test_host_request_notifications_sent_after_approval(db, push_collector: PushCollector, moderator):
2249 """
2250 Test that after a host request is approved, all notifications work normally.
2251 """
2252 host, host_token = generate_user(complete_profile=True)
2253 surfer, surfer_token = generate_user(complete_profile=True)
2255 today_plus_2 = (today() + timedelta(days=2)).isoformat()
2256 today_plus_3 = (today() + timedelta(days=3)).isoformat()
2258 # Create and approve host request
2259 with requests_session(surfer_token) as api:
2260 hr_id = api.CreateHostRequest(
2261 requests_pb2.CreateHostRequestReq(
2262 host_user_id=host.id,
2263 from_date=today_plus_2,
2264 to_date=today_plus_3,
2265 text=valid_request_text(),
2266 )
2267 ).host_request_id
2269 with mock_notification_email():
2270 moderator.approve_host_request(hr_id)
2272 # Host should have received 1 notification (the approval notification)
2273 push_collector.pop_for_user(host.id, last=True)
2275 # Host accepts the request - surfer should be notified
2276 with requests_session(host_token) as api:
2277 with mock_notification_email():
2278 api.RespondHostRequest(
2279 requests_pb2.RespondHostRequestReq(
2280 host_request_id=hr_id,
2281 status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
2282 text="Sure, come on over!",
2283 )
2284 )
2286 # Surfer should have 1 notification (the accept notification)
2287 push = push_collector.pop_for_user(surfer.id, last=True)
2288 assert push.content.title == f"{host.name} accepted your host request"
2290 # Surfer confirms - host should be notified
2291 with requests_session(surfer_token) as api:
2292 with mock_notification_email():
2293 api.RespondHostRequest(
2294 requests_pb2.RespondHostRequestReq(
2295 host_request_id=hr_id,
2296 status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED,
2297 text="See you then!",
2298 )
2299 )
2301 # Host should now have received the confirmation notifications
2302 push = push_collector.pop_for_user(host.id, last=True)
2303 assert push.content.title == f"{surfer.name} confirmed their host request"
2306def test_group_chat_message_notifications_suppressed_before_approval(db, push_collector: PushCollector, moderator):
2307 """
2308 Test that notifications are NOT sent for messages in group chats
2309 that haven't been approved yet.
2310 """
2311 from couchers.jobs.worker import process_job
2312 from couchers.models import GroupChat
2314 user1, token1 = generate_user(complete_profile=True)
2315 user2, token2 = generate_user(complete_profile=True)
2317 # Create a group chat (starts in SHADOWED state)
2318 with conversations_session(token1) as api:
2319 res = api.CreateGroupChat(
2320 conversations_pb2.CreateGroupChatReq(
2321 recipient_user_ids=[user2.id],
2322 )
2323 )
2324 gc_id = res.group_chat_id
2326 # Verify initial state
2327 with session_scope() as session:
2328 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2329 assert gc.moderation_state.visibility == ModerationVisibility.SHADOWED
2331 # No notifications should have been sent yet (chat is SHADOWED)
2332 assert push_collector.count_for_user(user2.id) == 0
2334 # Send messages BEFORE approval
2335 with conversations_session(token1) as api:
2336 api.SendMessage(
2337 conversations_pb2.SendMessageReq(
2338 group_chat_id=gc_id,
2339 text="Hello before approval",
2340 )
2341 )
2343 # Process the queued notification job
2344 while process_job():
2345 pass
2347 # User2 should STILL have no notifications (chat is SHADOWED)
2348 assert push_collector.count_for_user(user2.id) == 0
2350 # Now approve the group chat
2351 moderator.approve_group_chat(gc_id)
2353 # Process the queued notification jobs from approval
2354 while process_job():
2355 pass
2357 # Verify moderation state after approval
2358 with session_scope() as session:
2359 gc = session.execute(select(GroupChat).where(GroupChat.conversation_id == gc_id)).scalar_one()
2360 assert gc.moderation_state.visibility == ModerationVisibility.VISIBLE
2362 # User2 should have received 1 notification for the first message sent before approval
2363 push = push_collector.pop_for_user(user2.id, last=True)
2364 assert push.content.title == user1.name
2365 assert push.content.body == "Hello before approval"
2367 # Send a message AFTER approval
2368 with conversations_session(token1) as api:
2369 api.SendMessage(
2370 conversations_pb2.SendMessageReq(
2371 group_chat_id=gc_id,
2372 text="Hello after approval",
2373 )
2374 )
2376 # Process the queued notification job
2377 while process_job():
2378 pass
2380 # User2 should have received another notification
2381 assert push_collector.count_for_user(user2.id) == 1