""" Comprehensive tests for the moderation app. This module contains tests for: - EditSubmission state machine transitions - PhotoSubmission state machine transitions - ModerationReport state machine transitions - ModerationQueue state machine transitions - BulkOperation state machine transitions - FSM transition logging with django-fsm-log - Mixin functionality tests """ import json from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.models import ContentType from django.core.files.uploadedfile import SimpleUploadedFile from django.http import HttpRequest, JsonResponse from django.test import Client, RequestFactory, TestCase from django.utils import timezone from django.views.generic import DetailView from django_fsm import TransitionNotAllowed from apps.parks.models import Company as Operator from .mixins import ( AdminRequiredMixin, EditSubmissionMixin, HistoryMixin, InlineEditMixin, ModeratorRequiredMixin, PhotoSubmissionMixin, ) from .models import ( BulkOperation, EditSubmission, ModerationAction, ModerationQueue, ModerationReport, PhotoSubmission, ) User = get_user_model() class TestView( EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView, ): model = Operator template_name = "test.html" pk_url_kwarg = "pk" slug_url_kwarg = "slug" def get_context_data(self, **kwargs): if not hasattr(self, "object"): self.object = self.get_object() return super().get_context_data(**kwargs) def setup(self, request: HttpRequest, *args, **kwargs): super().setup(request, *args, **kwargs) self.request = request class ModerationMixinsTests(TestCase): def setUp(self): self.client = Client() self.factory = RequestFactory() # Create users with different roles self.user = User.objects.create_user( username="testuser", email="test@example.com", password="testpass123", ) self.moderator = User.objects.create_user( username="moderator", email="moderator@example.com", password="modpass123", role="MODERATOR", ) self.admin = User.objects.create_user( username="admin", email="admin@example.com", password="adminpass123", role="ADMIN", ) # Create test company self.operator = Operator.objects.create( name="Test Operator", website="http://example.com", description="Test Description", ) def test_edit_submission_mixin_unauthenticated(self): """Test edit submission when not logged in""" view = TestView() request = self.factory.post(f"/test/{self.operator.pk}/") request.user = AnonymousUser() view.setup(request, pk=self.operator.pk) view.kwargs = {"pk": self.operator.pk} response = view.handle_edit_submission(request, {}) self.assertIsInstance(response, JsonResponse) self.assertEqual(response.status_code, 403) def test_edit_submission_mixin_no_changes(self): """Test edit submission with no changes""" view = TestView() request = self.factory.post( f"/test/{self.operator.pk}/", data=json.dumps({}), content_type="application/json", ) request.user = self.user view.setup(request, pk=self.operator.pk) view.kwargs = {"pk": self.operator.pk} response = view.post(request) self.assertIsInstance(response, JsonResponse) self.assertEqual(response.status_code, 400) def test_edit_submission_mixin_invalid_json(self): """Test edit submission with invalid JSON""" view = TestView() request = self.factory.post( f"/test/{self.operator.pk}/", data="invalid json", content_type="application/json", ) request.user = self.user view.setup(request, pk=self.operator.pk) view.kwargs = {"pk": self.operator.pk} response = view.post(request) self.assertIsInstance(response, JsonResponse) self.assertEqual(response.status_code, 400) def test_edit_submission_mixin_regular_user(self): """Test edit submission as regular user""" view = TestView() request = self.factory.post(f"/test/{self.operator.pk}/") request.user = self.user view.setup(request, pk=self.operator.pk) view.kwargs = {"pk": self.operator.pk} changes = {"name": "New Name"} response = view.handle_edit_submission( request, changes, "Test reason", "Test source" ) self.assertIsInstance(response, JsonResponse) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode()) self.assertFalse(data["auto_approved"]) def test_edit_submission_mixin_moderator(self): """Test edit submission as moderator""" view = TestView() request = self.factory.post(f"/test/{self.operator.pk}/") request.user = self.moderator view.setup(request, pk=self.operator.pk) view.kwargs = {"pk": self.operator.pk} changes = {"name": "New Name"} response = view.handle_edit_submission( request, changes, "Test reason", "Test source" ) self.assertIsInstance(response, JsonResponse) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode()) self.assertTrue(data["auto_approved"]) def test_photo_submission_mixin_unauthenticated(self): """Test photo submission when not logged in""" view = TestView() view.kwargs = {"pk": self.operator.pk} view.object = self.operator request = self.factory.post( f"/test/{self.operator.pk}/", data={}, format="multipart" ) request.user = AnonymousUser() view.setup(request, pk=self.operator.pk) response = view.handle_photo_submission(request) self.assertIsInstance(response, JsonResponse) self.assertEqual(response.status_code, 403) def test_photo_submission_mixin_no_photo(self): """Test photo submission with no photo""" view = TestView() view.kwargs = {"pk": self.operator.pk} view.object = self.operator request = self.factory.post( f"/test/{self.operator.pk}/", data={}, format="multipart" ) request.user = self.user view.setup(request, pk=self.operator.pk) response = view.handle_photo_submission(request) self.assertIsInstance(response, JsonResponse) self.assertEqual(response.status_code, 400) def test_photo_submission_mixin_regular_user(self): """Test photo submission as regular user""" view = TestView() view.kwargs = {"pk": self.operator.pk} view.object = self.operator # Create a test photo file photo = SimpleUploadedFile( "test.gif", b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;", content_type="image/gif", ) request = self.factory.post( f"/test/{self.operator.pk}/", data={ "photo": photo, "caption": "Test Photo", "date_taken": "2024-01-01", }, format="multipart", ) request.user = self.user view.setup(request, pk=self.operator.pk) response = view.handle_photo_submission(request) self.assertIsInstance(response, JsonResponse) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode()) self.assertFalse(data["auto_approved"]) def test_photo_submission_mixin_moderator(self): """Test photo submission as moderator""" view = TestView() view.kwargs = {"pk": self.operator.pk} view.object = self.operator # Create a test photo file photo = SimpleUploadedFile( "test.gif", b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;", content_type="image/gif", ) request = self.factory.post( f"/test/{self.operator.pk}/", data={ "photo": photo, "caption": "Test Photo", "date_taken": "2024-01-01", }, format="multipart", ) request.user = self.moderator view.setup(request, pk=self.operator.pk) response = view.handle_photo_submission(request) self.assertIsInstance(response, JsonResponse) self.assertEqual(response.status_code, 200) data = json.loads(response.content.decode()) self.assertTrue(data["auto_approved"]) def test_moderator_required_mixin(self): """Test moderator required mixin""" class TestModeratorView(ModeratorRequiredMixin): pass view = TestModeratorView() # Test unauthenticated user request = self.factory.get("/test/") request.user = AnonymousUser() view.request = request self.assertFalse(view.test_func()) # Test regular user request.user = self.user view.request = request self.assertFalse(view.test_func()) # Test moderator request.user = self.moderator view.request = request self.assertTrue(view.test_func()) # Test admin request.user = self.admin view.request = request self.assertTrue(view.test_func()) def test_admin_required_mixin(self): """Test admin required mixin""" class TestAdminView(AdminRequiredMixin): pass view = TestAdminView() # Test unauthenticated user request = self.factory.get("/test/") request.user = AnonymousUser() view.request = request self.assertFalse(view.test_func()) # Test regular user request.user = self.user view.request = request self.assertFalse(view.test_func()) # Test moderator request.user = self.moderator view.request = request self.assertFalse(view.test_func()) # Test admin request.user = self.admin view.request = request self.assertTrue(view.test_func()) def test_inline_edit_mixin(self): """Test inline edit mixin""" view = TestView() view.kwargs = {"pk": self.operator.pk} view.object = self.operator # Test unauthenticated user request = self.factory.get(f"/test/{self.operator.pk}/") request.user = AnonymousUser() view.setup(request, pk=self.operator.pk) context = view.get_context_data() self.assertNotIn("can_edit", context) # Test regular user request.user = self.user view.setup(request, pk=self.operator.pk) context = view.get_context_data() self.assertTrue(context["can_edit"]) self.assertFalse(context["can_auto_approve"]) # Test moderator request.user = self.moderator view.setup(request, pk=self.operator.pk) context = view.get_context_data() self.assertTrue(context["can_edit"]) self.assertTrue(context["can_auto_approve"]) def test_history_mixin(self): """Test history mixin""" view = TestView() view.kwargs = {"pk": self.operator.pk} view.object = self.operator request = self.factory.get(f"/test/{self.operator.pk}/") request.user = self.user view.setup(request, pk=self.operator.pk) # Create some edit submissions EditSubmission.objects.create( user=self.user, content_type=ContentType.objects.get_for_model(Operator), object_id=getattr(self.operator, "id", None), submission_type="EDIT", changes={"name": "New Name"}, status="APPROVED", ) context = view.get_context_data() self.assertIn("history", context) self.assertIn("edit_submissions", context) self.assertEqual(len(context["edit_submissions"]), 1) # ============================================================================ # EditSubmission FSM Transition Tests # ============================================================================ class EditSubmissionTransitionTests(TestCase): """Comprehensive tests for EditSubmission FSM transitions.""" def setUp(self): """Set up test fixtures.""" self.user = User.objects.create_user( username='testuser', email='test@example.com', password='testpass123', role='USER' ) self.moderator = User.objects.create_user( username='moderator', email='moderator@example.com', password='testpass123', role='MODERATOR' ) self.admin = User.objects.create_user( username='admin', email='admin@example.com', password='testpass123', role='ADMIN' ) self.operator = Operator.objects.create( name='Test Operator', description='Test Description' ) self.content_type = ContentType.objects.get_for_model(Operator) def _create_submission(self, status='PENDING'): """Helper to create an EditSubmission.""" return EditSubmission.objects.create( user=self.user, content_type=self.content_type, object_id=self.operator.id, submission_type='EDIT', changes={'name': 'Updated Name'}, status=status, reason='Test reason' ) def test_pending_to_approved_transition(self): """Test transition from PENDING to APPROVED.""" submission = self._create_submission() self.assertEqual(submission.status, 'PENDING') submission.transition_to_approved(user=self.moderator) submission.handled_by = self.moderator submission.handled_at = timezone.now() submission.save() submission.refresh_from_db() self.assertEqual(submission.status, 'APPROVED') self.assertEqual(submission.handled_by, self.moderator) self.assertIsNotNone(submission.handled_at) def test_pending_to_rejected_transition(self): """Test transition from PENDING to REJECTED.""" submission = self._create_submission() self.assertEqual(submission.status, 'PENDING') submission.transition_to_rejected(user=self.moderator) submission.handled_by = self.moderator submission.handled_at = timezone.now() submission.notes = 'Rejected: Insufficient evidence' submission.save() submission.refresh_from_db() self.assertEqual(submission.status, 'REJECTED') self.assertEqual(submission.handled_by, self.moderator) self.assertIn('Rejected', submission.notes) def test_pending_to_escalated_transition(self): """Test transition from PENDING to ESCALATED.""" submission = self._create_submission() self.assertEqual(submission.status, 'PENDING') submission.transition_to_escalated(user=self.moderator) submission.handled_by = self.moderator submission.handled_at = timezone.now() submission.notes = 'Escalated: Needs admin review' submission.save() submission.refresh_from_db() self.assertEqual(submission.status, 'ESCALATED') def test_escalated_to_approved_transition(self): """Test transition from ESCALATED to APPROVED.""" submission = self._create_submission(status='ESCALATED') submission.transition_to_approved(user=self.admin) submission.handled_by = self.admin submission.handled_at = timezone.now() submission.save() submission.refresh_from_db() self.assertEqual(submission.status, 'APPROVED') self.assertEqual(submission.handled_by, self.admin) def test_escalated_to_rejected_transition(self): """Test transition from ESCALATED to REJECTED.""" submission = self._create_submission(status='ESCALATED') submission.transition_to_rejected(user=self.admin) submission.handled_by = self.admin submission.handled_at = timezone.now() submission.notes = 'Rejected by admin' submission.save() submission.refresh_from_db() self.assertEqual(submission.status, 'REJECTED') def test_invalid_transition_from_approved(self): """Test that transitions from APPROVED state fail.""" submission = self._create_submission(status='APPROVED') # Attempting to transition from APPROVED should raise TransitionNotAllowed with self.assertRaises(TransitionNotAllowed): submission.transition_to_rejected(user=self.moderator) def test_invalid_transition_from_rejected(self): """Test that transitions from REJECTED state fail.""" submission = self._create_submission(status='REJECTED') # Attempting to transition from REJECTED should raise TransitionNotAllowed with self.assertRaises(TransitionNotAllowed): submission.transition_to_approved(user=self.moderator) def test_approve_wrapper_method(self): """Test the approve() wrapper method.""" submission = self._create_submission() submission.approve(self.moderator) submission.refresh_from_db() self.assertEqual(submission.status, 'APPROVED') self.assertEqual(submission.handled_by, self.moderator) self.assertIsNotNone(submission.handled_at) def test_reject_wrapper_method(self): """Test the reject() wrapper method.""" submission = self._create_submission() submission.reject(self.moderator, reason='Not enough evidence') submission.refresh_from_db() self.assertEqual(submission.status, 'REJECTED') self.assertIn('Not enough evidence', submission.notes) def test_escalate_wrapper_method(self): """Test the escalate() wrapper method.""" submission = self._create_submission() submission.escalate(self.moderator, reason='Needs admin approval') submission.refresh_from_db() self.assertEqual(submission.status, 'ESCALATED') self.assertIn('Needs admin approval', submission.notes) # ============================================================================ # ModerationReport FSM Transition Tests # ============================================================================ class ModerationReportTransitionTests(TestCase): """Comprehensive tests for ModerationReport FSM transitions.""" def setUp(self): """Set up test fixtures.""" self.user = User.objects.create_user( username='reporter', email='reporter@example.com', password='testpass123', role='USER' ) self.moderator = User.objects.create_user( username='moderator', email='moderator@example.com', password='testpass123', role='MODERATOR' ) self.operator = Operator.objects.create( name='Test Operator', description='Test Description' ) self.content_type = ContentType.objects.get_for_model(Operator) def _create_report(self, status='PENDING'): """Helper to create a ModerationReport.""" return ModerationReport.objects.create( report_type='CONTENT', status=status, priority='MEDIUM', reported_entity_type='company', reported_entity_id=self.operator.id, content_type=self.content_type, reason='Inaccurate information', description='The company information is incorrect', reported_by=self.user ) def test_pending_to_under_review_transition(self): """Test transition from PENDING to UNDER_REVIEW.""" report = self._create_report() self.assertEqual(report.status, 'PENDING') report.transition_to_under_review(user=self.moderator) report.assigned_moderator = self.moderator report.save() report.refresh_from_db() self.assertEqual(report.status, 'UNDER_REVIEW') self.assertEqual(report.assigned_moderator, self.moderator) def test_under_review_to_resolved_transition(self): """Test transition from UNDER_REVIEW to RESOLVED.""" report = self._create_report(status='UNDER_REVIEW') report.assigned_moderator = self.moderator report.save() report.transition_to_resolved(user=self.moderator) report.resolution_action = 'Content updated' report.resolution_notes = 'Fixed the incorrect information' report.resolved_at = timezone.now() report.save() report.refresh_from_db() self.assertEqual(report.status, 'RESOLVED') self.assertIsNotNone(report.resolved_at) def test_under_review_to_dismissed_transition(self): """Test transition from UNDER_REVIEW to DISMISSED.""" report = self._create_report(status='UNDER_REVIEW') report.assigned_moderator = self.moderator report.save() report.transition_to_dismissed(user=self.moderator) report.resolution_notes = 'Report is not valid' report.resolved_at = timezone.now() report.save() report.refresh_from_db() self.assertEqual(report.status, 'DISMISSED') def test_invalid_transition_from_resolved(self): """Test that transitions from RESOLVED state fail.""" report = self._create_report(status='RESOLVED') with self.assertRaises(TransitionNotAllowed): report.transition_to_dismissed(user=self.moderator) def test_invalid_transition_from_dismissed(self): """Test that transitions from DISMISSED state fail.""" report = self._create_report(status='DISMISSED') with self.assertRaises(TransitionNotAllowed): report.transition_to_resolved(user=self.moderator) # ============================================================================ # ModerationQueue FSM Transition Tests # ============================================================================ class ModerationQueueTransitionTests(TestCase): """Comprehensive tests for ModerationQueue FSM transitions.""" def setUp(self): """Set up test fixtures.""" self.moderator = User.objects.create_user( username='moderator', email='moderator@example.com', password='testpass123', role='MODERATOR' ) def _create_queue_item(self, status='PENDING'): """Helper to create a ModerationQueue item.""" return ModerationQueue.objects.create( item_type='EDIT_SUBMISSION', status=status, priority='MEDIUM', title='Review edit submission', description='User submitted an edit that needs review', flagged_by=self.moderator ) def test_pending_to_in_progress_transition(self): """Test transition from PENDING to IN_PROGRESS.""" item = self._create_queue_item() self.assertEqual(item.status, 'PENDING') item.transition_to_in_progress(user=self.moderator) item.assigned_to = self.moderator item.assigned_at = timezone.now() item.save() item.refresh_from_db() self.assertEqual(item.status, 'IN_PROGRESS') self.assertEqual(item.assigned_to, self.moderator) def test_in_progress_to_completed_transition(self): """Test transition from IN_PROGRESS to COMPLETED.""" item = self._create_queue_item(status='IN_PROGRESS') item.assigned_to = self.moderator item.save() item.transition_to_completed(user=self.moderator) item.save() item.refresh_from_db() self.assertEqual(item.status, 'COMPLETED') def test_in_progress_to_cancelled_transition(self): """Test transition from IN_PROGRESS to CANCELLED.""" item = self._create_queue_item(status='IN_PROGRESS') item.assigned_to = self.moderator item.save() item.transition_to_cancelled(user=self.moderator) item.save() item.refresh_from_db() self.assertEqual(item.status, 'CANCELLED') def test_pending_to_cancelled_transition(self): """Test transition from PENDING to CANCELLED.""" item = self._create_queue_item() item.transition_to_cancelled(user=self.moderator) item.save() item.refresh_from_db() self.assertEqual(item.status, 'CANCELLED') def test_invalid_transition_from_completed(self): """Test that transitions from COMPLETED state fail.""" item = self._create_queue_item(status='COMPLETED') with self.assertRaises(TransitionNotAllowed): item.transition_to_in_progress(user=self.moderator) # ============================================================================ # BulkOperation FSM Transition Tests # ============================================================================ class BulkOperationTransitionTests(TestCase): """Comprehensive tests for BulkOperation FSM transitions.""" def setUp(self): """Set up test fixtures.""" self.admin = User.objects.create_user( username='admin', email='admin@example.com', password='testpass123', role='ADMIN' ) def _create_bulk_operation(self, status='PENDING'): """Helper to create a BulkOperation.""" return BulkOperation.objects.create( operation_type='BULK_UPDATE', status=status, priority='MEDIUM', description='Bulk update park statuses', parameters={'target': 'parks', 'action': 'update_status'}, created_by=self.admin, total_items=100 ) def test_pending_to_running_transition(self): """Test transition from PENDING to RUNNING.""" operation = self._create_bulk_operation() self.assertEqual(operation.status, 'PENDING') operation.transition_to_running(user=self.admin) operation.started_at = timezone.now() operation.save() operation.refresh_from_db() self.assertEqual(operation.status, 'RUNNING') self.assertIsNotNone(operation.started_at) def test_running_to_completed_transition(self): """Test transition from RUNNING to COMPLETED.""" operation = self._create_bulk_operation(status='RUNNING') operation.started_at = timezone.now() operation.save() operation.transition_to_completed(user=self.admin) operation.completed_at = timezone.now() operation.processed_items = 100 operation.save() operation.refresh_from_db() self.assertEqual(operation.status, 'COMPLETED') self.assertIsNotNone(operation.completed_at) self.assertEqual(operation.processed_items, 100) def test_running_to_failed_transition(self): """Test transition from RUNNING to FAILED.""" operation = self._create_bulk_operation(status='RUNNING') operation.started_at = timezone.now() operation.save() operation.transition_to_failed(user=self.admin) operation.completed_at = timezone.now() operation.results = {'error': 'Database connection failed'} operation.failed_items = 50 operation.save() operation.refresh_from_db() self.assertEqual(operation.status, 'FAILED') self.assertEqual(operation.failed_items, 50) def test_pending_to_cancelled_transition(self): """Test transition from PENDING to CANCELLED.""" operation = self._create_bulk_operation() operation.transition_to_cancelled(user=self.admin) operation.save() operation.refresh_from_db() self.assertEqual(operation.status, 'CANCELLED') def test_running_to_cancelled_transition(self): """Test transition from RUNNING to CANCELLED when cancellable.""" operation = self._create_bulk_operation(status='RUNNING') operation.can_cancel = True operation.save() operation.transition_to_cancelled(user=self.admin) operation.save() operation.refresh_from_db() self.assertEqual(operation.status, 'CANCELLED') def test_invalid_transition_from_completed(self): """Test that transitions from COMPLETED state fail.""" operation = self._create_bulk_operation(status='COMPLETED') with self.assertRaises(TransitionNotAllowed): operation.transition_to_running(user=self.admin) def test_invalid_transition_from_failed(self): """Test that transitions from FAILED state fail.""" operation = self._create_bulk_operation(status='FAILED') with self.assertRaises(TransitionNotAllowed): operation.transition_to_completed(user=self.admin) def test_progress_percentage_calculation(self): """Test progress percentage property.""" operation = self._create_bulk_operation() operation.total_items = 100 operation.processed_items = 50 self.assertEqual(operation.progress_percentage, 50.0) operation.processed_items = 0 self.assertEqual(operation.progress_percentage, 0.0) operation.total_items = 0 self.assertEqual(operation.progress_percentage, 0.0) # ============================================================================ # FSM Transition Logging Tests # ============================================================================ class TransitionLoggingTestCase(TestCase): """Test cases for FSM transition logging with django-fsm-log.""" def setUp(self): """Set up test fixtures.""" self.user = User.objects.create_user( username='testuser', email='test@example.com', password='testpass123', role='USER' ) self.moderator = User.objects.create_user( username='moderator', email='moderator@example.com', password='testpass123', role='MODERATOR' ) self.operator = Operator.objects.create( name='Test Operator', description='Test Description' ) self.content_type = ContentType.objects.get_for_model(Operator) def test_transition_creates_log(self): """Test that transitions create StateLog entries.""" from django_fsm_log.models import StateLog # Create a submission submission = EditSubmission.objects.create( user=self.user, content_type=self.content_type, object_id=self.operator.id, submission_type='EDIT', changes={'name': 'Updated Name'}, status='PENDING', reason='Test reason' ) # Perform transition submission.transition_to_approved(user=self.moderator) submission.save() # Check log was created submission_ct = ContentType.objects.get_for_model(submission) log = StateLog.objects.filter( content_type=submission_ct, object_id=submission.id ).first() self.assertIsNotNone(log, "StateLog entry should be created") self.assertEqual(log.state, 'APPROVED') self.assertEqual(log.by, self.moderator) self.assertIn('approved', log.transition.lower()) def test_multiple_transitions_logged(self): """Test that multiple transitions are all logged.""" from django_fsm_log.models import StateLog submission = EditSubmission.objects.create( user=self.user, content_type=self.content_type, object_id=self.operator.id, submission_type='EDIT', changes={'name': 'Updated Name'}, status='PENDING', reason='Test reason' ) submission_ct = ContentType.objects.get_for_model(submission) # First transition submission.transition_to_escalated(user=self.moderator) submission.save() # Second transition submission.transition_to_approved(user=self.moderator) submission.save() # Check multiple logs created logs = StateLog.objects.filter( content_type=submission_ct, object_id=submission.id ).order_by('timestamp') self.assertEqual(logs.count(), 2, "Should have 2 log entries") self.assertEqual(logs[0].state, 'ESCALATED') self.assertEqual(logs[1].state, 'APPROVED') def test_history_endpoint_returns_logs(self): """Test history API endpoint returns transition logs.""" from rest_framework.test import APIClient api_client = APIClient() api_client.force_authenticate(user=self.moderator) submission = EditSubmission.objects.create( user=self.user, content_type=self.content_type, object_id=self.operator.id, submission_type='EDIT', changes={'name': 'Updated Name'}, status='PENDING', reason='Test reason' ) # Perform transition to create log submission.transition_to_approved(user=self.moderator) submission.save() # Note: This assumes EditSubmission has a history endpoint # Adjust URL pattern based on actual implementation response = api_client.get('/api/moderation/reports/all_history/') self.assertEqual(response.status_code, 200) def test_system_transitions_without_user(self): """Test that system transitions work without a user.""" from django_fsm_log.models import StateLog submission = EditSubmission.objects.create( user=self.user, content_type=self.content_type, object_id=self.operator.id, submission_type='EDIT', changes={'name': 'Updated Name'}, status='PENDING', reason='Test reason' ) # Perform transition without user submission.transition_to_rejected(user=None) submission.save() # Check log was created even without user submission_ct = ContentType.objects.get_for_model(submission) log = StateLog.objects.filter( content_type=submission_ct, object_id=submission.id ).first() self.assertIsNotNone(log) self.assertEqual(log.state, 'REJECTED') self.assertIsNone(log.by, "System transitions should have no user") def test_transition_log_includes_description(self): """Test that transition logs can include descriptions.""" from django_fsm_log.models import StateLog submission = EditSubmission.objects.create( user=self.user, content_type=self.content_type, object_id=self.operator.id, submission_type='EDIT', changes={'name': 'Updated Name'}, status='PENDING', reason='Test reason' ) # Perform transition submission.transition_to_approved(user=self.moderator) submission.save() # Check log submission_ct = ContentType.objects.get_for_model(submission) log = StateLog.objects.filter( content_type=submission_ct, object_id=submission.id ).first() self.assertIsNotNone(log) # Description field exists and can be used for audit trails self.assertTrue(hasattr(log, 'description')) def test_log_ordering_by_timestamp(self): """Test that logs are properly ordered by timestamp.""" from django_fsm_log.models import StateLog submission = EditSubmission.objects.create( user=self.user, content_type=self.content_type, object_id=self.operator.id, submission_type='EDIT', changes={'name': 'Updated Name'}, status='PENDING', reason='Test reason' ) submission_ct = ContentType.objects.get_for_model(submission) # Create multiple transitions submission.transition_to_escalated(user=self.moderator) submission.save() submission.transition_to_approved(user=self.moderator) submission.save() # Get logs ordered by timestamp logs = list(StateLog.objects.filter( content_type=submission_ct, object_id=submission.id ).order_by('timestamp')) # Verify ordering self.assertEqual(len(logs), 2) self.assertTrue(logs[0].timestamp <= logs[1].timestamp) # ============================================================================ # ModerationAction Model Tests # ============================================================================ class ModerationActionTests(TestCase): """Tests for ModerationAction model.""" def setUp(self): """Set up test fixtures.""" self.moderator = User.objects.create_user( username='moderator', email='moderator@example.com', password='testpass123', role='MODERATOR' ) self.target_user = User.objects.create_user( username='target', email='target@example.com', password='testpass123', role='USER' ) def test_create_action_with_duration(self): """Test creating an action with duration sets expires_at.""" action = ModerationAction.objects.create( action_type='TEMPORARY_BAN', reason='Spam', details='User was spamming the forums', duration_hours=24, moderator=self.moderator, target_user=self.target_user ) self.assertIsNotNone(action.expires_at) # expires_at should be approximately 24 hours from now time_diff = action.expires_at - timezone.now() self.assertAlmostEqual(time_diff.total_seconds(), 24 * 3600, delta=60) def test_create_action_without_duration(self): """Test creating an action without duration has no expires_at.""" action = ModerationAction.objects.create( action_type='WARNING', reason='First offense', details='Warning issued for minor violation', moderator=self.moderator, target_user=self.target_user ) self.assertIsNone(action.expires_at) def test_action_is_active_by_default(self): """Test that new actions are active by default.""" action = ModerationAction.objects.create( action_type='WARNING', reason='Test', details='Test warning', moderator=self.moderator, target_user=self.target_user ) self.assertTrue(action.is_active) # ============================================================================ # PhotoSubmission FSM Transition Tests # ============================================================================ class PhotoSubmissionTransitionTests(TestCase): """Comprehensive tests for PhotoSubmission FSM transitions.""" def setUp(self): """Set up test fixtures.""" self.user = User.objects.create_user( username='testuser', email='test@example.com', password='testpass123', role='USER' ) self.moderator = User.objects.create_user( username='moderator', email='moderator@example.com', password='testpass123', role='MODERATOR' ) self.admin = User.objects.create_user( username='admin', email='admin@example.com', password='testpass123', role='ADMIN' ) self.operator = Operator.objects.create( name='Test Operator', description='Test Description', roles=['OPERATOR'] ) self.content_type = ContentType.objects.get_for_model(Operator) def _create_mock_photo(self): """Create a mock CloudflareImage for testing.""" from unittest.mock import Mock mock_photo = Mock() mock_photo.pk = 1 mock_photo.id = 1 return mock_photo def _create_submission(self, status='PENDING'): """Helper to create a PhotoSubmission.""" # Create using direct database creation to bypass FK validation from unittest.mock import Mock, patch with patch.object(PhotoSubmission, 'photo', Mock()): submission = PhotoSubmission( user=self.user, content_type=self.content_type, object_id=self.operator.id, caption='Test Photo', status=status, ) # Bypass model save to avoid FK constraint on photo submission.photo_id = 1 submission.save(update_fields=None) # Force status after creation for non-PENDING states if status != 'PENDING': PhotoSubmission.objects.filter(pk=submission.pk).update(status=status) submission.refresh_from_db() return submission def test_pending_to_approved_transition(self): """Test transition from PENDING to APPROVED.""" submission = self._create_submission() self.assertEqual(submission.status, 'PENDING') submission.transition_to_approved(user=self.moderator) submission.handled_by = self.moderator submission.handled_at = timezone.now() submission.save() submission.refresh_from_db() self.assertEqual(submission.status, 'APPROVED') self.assertEqual(submission.handled_by, self.moderator) self.assertIsNotNone(submission.handled_at) def test_pending_to_rejected_transition(self): """Test transition from PENDING to REJECTED.""" submission = self._create_submission() self.assertEqual(submission.status, 'PENDING') submission.transition_to_rejected(user=self.moderator) submission.handled_by = self.moderator submission.handled_at = timezone.now() submission.notes = 'Rejected: Image quality too low' submission.save() submission.refresh_from_db() self.assertEqual(submission.status, 'REJECTED') self.assertEqual(submission.handled_by, self.moderator) self.assertIn('Rejected', submission.notes) def test_pending_to_escalated_transition(self): """Test transition from PENDING to ESCALATED.""" submission = self._create_submission() self.assertEqual(submission.status, 'PENDING') submission.transition_to_escalated(user=self.moderator) submission.handled_by = self.moderator submission.handled_at = timezone.now() submission.notes = 'Escalated: Copyright concerns' submission.save() submission.refresh_from_db() self.assertEqual(submission.status, 'ESCALATED') def test_escalated_to_approved_transition(self): """Test transition from ESCALATED to APPROVED.""" submission = self._create_submission(status='ESCALATED') submission.transition_to_approved(user=self.admin) submission.handled_by = self.admin submission.handled_at = timezone.now() submission.save() submission.refresh_from_db() self.assertEqual(submission.status, 'APPROVED') self.assertEqual(submission.handled_by, self.admin) def test_escalated_to_rejected_transition(self): """Test transition from ESCALATED to REJECTED.""" submission = self._create_submission(status='ESCALATED') submission.transition_to_rejected(user=self.admin) submission.handled_by = self.admin submission.handled_at = timezone.now() submission.notes = 'Rejected by admin after review' submission.save() submission.refresh_from_db() self.assertEqual(submission.status, 'REJECTED') def test_invalid_transition_from_approved(self): """Test that transitions from APPROVED state fail.""" submission = self._create_submission(status='APPROVED') with self.assertRaises(TransitionNotAllowed): submission.transition_to_rejected(user=self.moderator) def test_invalid_transition_from_rejected(self): """Test that transitions from REJECTED state fail.""" submission = self._create_submission(status='REJECTED') with self.assertRaises(TransitionNotAllowed): submission.transition_to_approved(user=self.moderator) def test_reject_wrapper_method(self): """Test the reject() wrapper method.""" from unittest.mock import patch submission = self._create_submission() # Mock the photo creation part since we don't have actual photos with patch.object(submission, 'transition_to_rejected'): submission.reject(self.moderator, notes='Not suitable') submission.refresh_from_db() self.assertEqual(submission.status, 'REJECTED') self.assertIn('Not suitable', submission.notes) def test_escalate_wrapper_method(self): """Test the escalate() wrapper method.""" from unittest.mock import patch submission = self._create_submission() with patch.object(submission, 'transition_to_escalated'): submission.escalate(self.moderator, notes='Needs admin review') submission.refresh_from_db() self.assertEqual(submission.status, 'ESCALATED') self.assertIn('Needs admin review', submission.notes) def test_transition_creates_state_log(self): """Test that transitions create StateLog entries.""" from django_fsm_log.models import StateLog submission = self._create_submission() # Perform transition submission.transition_to_approved(user=self.moderator) submission.save() # Check log was created submission_ct = ContentType.objects.get_for_model(submission) log = StateLog.objects.filter( content_type=submission_ct, object_id=submission.id ).first() self.assertIsNotNone(log, "StateLog entry should be created") self.assertEqual(log.state, 'APPROVED') self.assertEqual(log.by, self.moderator) def test_multiple_transitions_logged(self): """Test that multiple transitions are all logged.""" from django_fsm_log.models import StateLog submission = self._create_submission() submission_ct = ContentType.objects.get_for_model(submission) # First transition: PENDING -> ESCALATED submission.transition_to_escalated(user=self.moderator) submission.save() # Second transition: ESCALATED -> APPROVED submission.transition_to_approved(user=self.admin) submission.save() # Check multiple logs created logs = StateLog.objects.filter( content_type=submission_ct, object_id=submission.id ).order_by('timestamp') self.assertEqual(logs.count(), 2, "Should have 2 log entries") self.assertEqual(logs[0].state, 'ESCALATED') self.assertEqual(logs[1].state, 'APPROVED') def test_handled_by_and_handled_at_updated(self): """Test that handled_by and handled_at are properly updated.""" submission = self._create_submission() self.assertIsNone(submission.handled_by) self.assertIsNone(submission.handled_at) before_time = timezone.now() submission.transition_to_approved(user=self.moderator) submission.handled_by = self.moderator submission.handled_at = timezone.now() submission.save() after_time = timezone.now() submission.refresh_from_db() self.assertEqual(submission.handled_by, self.moderator) self.assertIsNotNone(submission.handled_at) self.assertTrue(before_time <= submission.handled_at <= after_time) def test_notes_field_updated_on_rejection(self): """Test that notes field is updated with rejection reason.""" submission = self._create_submission() rejection_reason = 'Image contains watermarks' submission.transition_to_rejected(user=self.moderator) submission.notes = rejection_reason submission.save() submission.refresh_from_db() self.assertEqual(submission.notes, rejection_reason) def test_notes_field_updated_on_escalation(self): """Test that notes field is updated with escalation reason.""" submission = self._create_submission() escalation_reason = 'Potentially copyrighted content' submission.transition_to_escalated(user=self.moderator) submission.notes = escalation_reason submission.save() submission.refresh_from_db() self.assertEqual(submission.notes, escalation_reason)