""" 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 MixinTestView( EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView, ): """Helper view for testing moderation mixins. Not a test class.""" 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 = MixinTestView() 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 = MixinTestView() 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 = MixinTestView() 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 = MixinTestView() 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 = MixinTestView() 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 = MixinTestView() 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 = MixinTestView() 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 = MixinTestView() 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 = MixinTestView() 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 = MixinTestView() 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 = MixinTestView() 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_claimed_to_approved_transition(self): """Test transition from PENDING to CLAIMED to APPROVED (mandatory flow).""" submission = self._create_submission() self.assertEqual(submission.status, "PENDING") # Must claim first submission.claim(user=self.moderator) submission.refresh_from_db() self.assertEqual(submission.status, "CLAIMED") # Now can approve 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_claimed_to_rejected_transition(self): """Test transition from PENDING to CLAIMED to REJECTED (mandatory flow).""" submission = self._create_submission() self.assertEqual(submission.status, "PENDING") # Must claim first submission.claim(user=self.moderator) submission.refresh_from_db() self.assertEqual(submission.status, "CLAIMED") # Now can reject 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_claimed_to_escalated_transition(self): """Test transition from PENDING to CLAIMED to ESCALATED (mandatory flow).""" submission = self._create_submission() self.assertEqual(submission.status, "PENDING") # Must claim first submission.claim(user=self.moderator) submission.refresh_from_db() self.assertEqual(submission.status, "CLAIMED") # Now can escalate 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 (requires CLAIMED state first).""" submission = self._create_submission() # Must claim first submission.claim(user=self.moderator) submission.refresh_from_db() self.assertEqual(submission.status, "CLAIMED") # Now can approve 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 (requires CLAIMED state first).""" submission = self._create_submission() # Must claim first submission.claim(user=self.moderator) submission.refresh_from_db() self.assertEqual(submission.status, "CLAIMED") # Now can reject 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 (requires CLAIMED state first).""" submission = self._create_submission() # Must claim first submission.claim(user=self.moderator) submission.refresh_from_db() self.assertEqual(submission.status, "CLAIMED") # Now can escalate 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", ) # Must claim first (FSM requirement) submission.claim(user=self.moderator) submission.refresh_from_db() # 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, state="APPROVED" ).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 = 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 claim (FSM requirement) submission.claim(user=self.moderator) submission.refresh_from_db() # First transition: CLAIMED -> ESCALATED submission.transition_to_escalated(user=self.moderator) submission.save() # Second transition: ESCALATED -> APPROVED submission.transition_to_approved(user=self.moderator) submission.save() # Check logs created (excluding the claim transition log) logs = StateLog.objects.filter( content_type=submission_ct, object_id=submission.id ).order_by("timestamp") # Should have at least 2 entries for ESCALATED and APPROVED self.assertGreaterEqual(logs.count(), 2, "Should have at least 2 log entries") states = [log.state for log in logs] self.assertIn("ESCALATED", states) self.assertIn("APPROVED", states) 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", ) # Must claim first (FSM requirement) submission.claim(user=self.moderator) submission.refresh_from_db() # 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 (admin/cron operations).""" 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", ) # Must claim first (FSM requirement) submission.claim(user=self.moderator) submission.refresh_from_db() # Perform transition without user (simulating system/cron action) 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, state="REJECTED" ).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", ) # Must claim first (FSM requirement) submission.claim(user=self.moderator) submission.refresh_from_db() # 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, state="APPROVED" ).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) # Must claim first (FSM requirement) submission.claim(user=self.moderator) submission.refresh_from_db() # 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 - should have at least 2 logs (escalated and approved) self.assertGreaterEqual(len(logs), 2) # Verify timestamps are ordered for i in range(len(logs) - 1): self.assertTrue(logs[i].timestamp <= logs[i + 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. Note: All approve/reject/escalate transitions require CLAIMED state first. """ def setUp(self): """Set up test fixtures.""" from datetime import timedelta from django_cloudflareimages_toolkit.models import CloudflareImage 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) # Create a real CloudflareImage for tests (required by FK constraint) self.mock_image = CloudflareImage.objects.create( cloudflare_id=f"test-cf-photo-{id(self)}", user=self.user, expires_at=timezone.now() + timedelta(days=365), ) def _create_submission(self, status="PENDING"): """Helper to create a PhotoSubmission with proper CloudflareImage.""" submission = PhotoSubmission.objects.create( user=self.user, content_type=self.content_type, object_id=self.operator.id, photo=self.mock_image, caption="Test Photo", status="PENDING", # Always create as PENDING first ) # For non-PENDING states, we need to transition through CLAIMED if status == "CLAIMED": submission.claim(user=self.moderator) submission.refresh_from_db() elif status in ("APPROVED", "REJECTED", "ESCALATED"): # First claim, then transition to target state submission.claim(user=self.moderator) if status == "APPROVED": submission.transition_to_approved(user=self.moderator) elif status == "REJECTED": submission.transition_to_rejected(user=self.moderator) elif status == "ESCALATED": submission.transition_to_escalated(user=self.moderator) submission.save() submission.refresh_from_db() return submission def test_pending_to_claimed_transition(self): """Test transition from PENDING to CLAIMED.""" submission = self._create_submission() self.assertEqual(submission.status, "PENDING") submission.claim(user=self.moderator) submission.refresh_from_db() self.assertEqual(submission.status, "CLAIMED") self.assertEqual(submission.claimed_by, self.moderator) self.assertIsNotNone(submission.claimed_at) def test_claimed_to_approved_transition(self): """Test transition from CLAIMED to APPROVED (mandatory flow).""" submission = self._create_submission(status="CLAIMED") self.assertEqual(submission.status, "CLAIMED") 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_claimed_to_rejected_transition(self): """Test transition from CLAIMED to REJECTED (mandatory flow).""" submission = self._create_submission(status="CLAIMED") self.assertEqual(submission.status, "CLAIMED") 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_claimed_to_escalated_transition(self): """Test transition from CLAIMED to ESCALATED (mandatory flow).""" submission = self._create_submission(status="CLAIMED") self.assertEqual(submission.status, "CLAIMED") 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 (requires CLAIMED state first).""" submission = self._create_submission(status="CLAIMED") 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 (requires CLAIMED state first).""" submission = self._create_submission(status="CLAIMED") 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(status="CLAIMED") # 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(status="CLAIMED") submission_ct = ContentType.objects.get_for_model(submission) # First transition: CLAIMED -> 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(status="CLAIMED") 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(status="CLAIMED") 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(status="CLAIMED") 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)