""" 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 """ from django.test import TestCase, Client 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 JsonResponse, HttpRequest from django.utils import timezone from django_fsm import TransitionNotAllowed from .models import ( EditSubmission, PhotoSubmission, ModerationReport, ModerationQueue, BulkOperation, ModerationAction, ) from .mixins import ( EditSubmissionMixin, PhotoSubmissionMixin, ModeratorRequiredMixin, AdminRequiredMixin, InlineEditMixin, HistoryMixin, ) from apps.parks.models import Company as Operator from django.views.generic import DetailView from django.test import RequestFactory import json 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() result = 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 import time 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 patch, Mock 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)