mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 04:25:17 -05:00
- Add drf_spectacular imports (extend_schema, OpenApiResponse, inline_serializer) - Annotate claim action with response schemas for 200/404/409/400 - Annotate unclaim action with response schemas for 200/403/400 - Annotate approve action with request=None and response schemas - Annotate reject action with reason request body schema - Annotate escalate action with reason request body schema - All actions tagged with 'Moderation' for API docs grouping
1402 lines
52 KiB
Python
1402 lines
52 KiB
Python
"""
|
|
Comprehensive tests for the moderation app.
|
|
|
|
This module contains tests for:
|
|
- EditSubmission state machine transitions
|
|
- EditSubmission with submission_type="PHOTO" (photo submissions)
|
|
- 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,
|
|
)
|
|
|
|
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)
|
|
|
|
|
|
# ============================================================================
|
|
# EditSubmission PHOTO Type FSM Transition Tests
|
|
# ============================================================================
|
|
|
|
|
|
class PhotoEditSubmissionTransitionTests(TestCase):
|
|
"""Comprehensive tests for EditSubmission with submission_type='PHOTO' FSM transitions.
|
|
|
|
Note: All approve/reject/escalate transitions require CLAIMED state first.
|
|
|
|
These tests validate that photo submissions (using the unified EditSubmission model)
|
|
have correct FSM behavior.
|
|
"""
|
|
|
|
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 an EditSubmission with submission_type='PHOTO' and proper CloudflareImage."""
|
|
submission = EditSubmission.objects.create(
|
|
user=self.user,
|
|
content_type=self.content_type,
|
|
object_id=self.operator.id,
|
|
submission_type="PHOTO", # Unified model
|
|
photo=self.mock_image,
|
|
caption="Test Photo",
|
|
changes={}, # Photos use empty changes
|
|
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)
|
|
|