""" E2E Tests for Moderation FSM Transitions Tests the complete HTMX FSM transition flow for moderation-related models: - EditSubmission: approve, reject, escalate - PhotoSubmission: approve, reject - ModerationQueue: complete, cancel - BulkOperation: cancel These tests verify: - Status badge updates correctly after transition - Toast notifications appear with correct message/type - HTMX swap occurs without full page reload - StateLog entry created in database """ import pytest from playwright.sync_api import Page, expect from django.contrib.contenttypes.models import ContentType @pytest.fixture def pending_submission(db): """Create a pending EditSubmission for testing.""" from django.contrib.auth import get_user_model from apps.moderation.models import EditSubmission from apps.parks.models import Park User = get_user_model() # Get or create test user user, _ = User.objects.get_or_create( username="testsubmitter", defaults={"email": "testsubmitter@example.com"} ) user.set_password("testpass123") user.save() # Get or create a park for the submission park = Park.objects.first() if not park: pytest.skip("No parks available for testing") content_type = ContentType.objects.get_for_model(Park) submission = EditSubmission.objects.create( user=user, content_type=content_type, object_id=park.pk, submission_type="EDIT", changes={"description": "Updated park description for testing"}, reason="E2E test submission", status="PENDING" ) yield submission # Cleanup submission.delete() @pytest.fixture def pending_photo_submission(db): """Create a pending PhotoSubmission for testing.""" from django.contrib.auth import get_user_model from apps.moderation.models import PhotoSubmission from apps.parks.models import Park from django.contrib.contenttypes.models import ContentType User = get_user_model() # Get or create test user user, _ = User.objects.get_or_create( username="testphotosubmitter", defaults={"email": "testphotosubmitter@example.com"} ) user.set_password("testpass123") user.save() # Get or create a park park = Park.objects.first() if not park: pytest.skip("No parks available for testing") content_type = ContentType.objects.get_for_model(Park) # Check if CloudflareImage model exists and has entries try: from django_cloudflareimages_toolkit.models import CloudflareImage photo = CloudflareImage.objects.first() if not photo: pytest.skip("No CloudflareImage available for testing") except ImportError: pytest.skip("CloudflareImage not available") submission = PhotoSubmission.objects.create( user=user, content_type=content_type, object_id=park.pk, photo=photo, caption="E2E test photo", status="PENDING" ) yield submission # Cleanup submission.delete() class TestEditSubmissionTransitions: """Tests for EditSubmission FSM transitions via HTMX.""" def test_submission_approve_transition_as_moderator( self, mod_page: Page, pending_submission, live_server ): """Test approving an EditSubmission as a moderator.""" # Navigate to moderation dashboard mod_page.goto(f"{live_server.url}/moderation/dashboard/") # Wait for the page to load mod_page.wait_for_load_state("networkidle") # Find the submission row submission_row = mod_page.locator(f'[data-submission-id="{pending_submission.pk}"]') # Verify initial status is pending status_badge = submission_row.locator('[data-status-badge]') expect(status_badge).to_contain_text("Pending") # Click the approve button approve_btn = submission_row.get_by_role("button", name="Approve") # Handle confirmation dialog if present mod_page.on("dialog", lambda dialog: dialog.accept()) # Click and wait for HTMX swap approve_btn.click() # Wait for toast notification toast = mod_page.locator('[data-toast]') expect(toast).to_be_visible(timeout=5000) expect(toast).to_contain_text("approved") # Verify status badge updated expect(status_badge).to_contain_text("Approved") # Verify database state from apps.moderation.models import EditSubmission pending_submission.refresh_from_db() assert pending_submission.status == "APPROVED" def test_submission_reject_transition_as_moderator( self, mod_page: Page, pending_submission, live_server ): """Test rejecting an EditSubmission as a moderator.""" mod_page.goto(f"{live_server.url}/moderation/dashboard/") mod_page.wait_for_load_state("networkidle") submission_row = mod_page.locator(f'[data-submission-id="{pending_submission.pk}"]') # Verify initial status status_badge = submission_row.locator('[data-status-badge]') expect(status_badge).to_contain_text("Pending") # Click reject button reject_btn = submission_row.get_by_role("button", name="Reject") # Handle confirmation dialog mod_page.on("dialog", lambda dialog: dialog.accept()) reject_btn.click() # Verify toast notification toast = mod_page.locator('[data-toast]') expect(toast).to_be_visible(timeout=5000) expect(toast).to_contain_text("rejected") # Verify status badge updated (should show red/danger styling) expect(status_badge).to_contain_text("Rejected") # Verify database state from apps.moderation.models import EditSubmission pending_submission.refresh_from_db() assert pending_submission.status == "REJECTED" def test_submission_escalate_transition_as_moderator( self, mod_page: Page, pending_submission, live_server ): """Test escalating an EditSubmission as a moderator.""" mod_page.goto(f"{live_server.url}/moderation/dashboard/") mod_page.wait_for_load_state("networkidle") submission_row = mod_page.locator(f'[data-submission-id="{pending_submission.pk}"]') # Verify initial status status_badge = submission_row.locator('[data-status-badge]') expect(status_badge).to_contain_text("Pending") # Click escalate button escalate_btn = submission_row.get_by_role("button", name="Escalate") # Handle confirmation dialog mod_page.on("dialog", lambda dialog: dialog.accept()) escalate_btn.click() # Verify toast notification toast = mod_page.locator('[data-toast]') expect(toast).to_be_visible(timeout=5000) expect(toast).to_contain_text("escalated") # Verify status badge updated expect(status_badge).to_contain_text("Escalated") # Verify database state from apps.moderation.models import EditSubmission pending_submission.refresh_from_db() assert pending_submission.status == "ESCALATED" class TestPhotoSubmissionTransitions: """Tests for PhotoSubmission FSM transitions via HTMX.""" def test_photo_submission_approve_transition( self, mod_page: Page, pending_photo_submission, live_server ): """Test approving a PhotoSubmission as a moderator.""" mod_page.goto(f"{live_server.url}/moderation/dashboard/") mod_page.wait_for_load_state("networkidle") # Click on Photos tab if it exists photos_tab = mod_page.get_by_role("tab", name="Photos") if photos_tab.is_visible(): photos_tab.click() # Find the photo submission row submission_row = mod_page.locator( f'[data-photo-submission-id="{pending_photo_submission.pk}"]' ) if not submission_row.is_visible(): pytest.skip("Photo submission not visible in dashboard") # Click approve button approve_btn = submission_row.get_by_role("button", name="Approve") mod_page.on("dialog", lambda dialog: dialog.accept()) approve_btn.click() # Verify toast notification toast = mod_page.locator('[data-toast]') expect(toast).to_be_visible(timeout=5000) expect(toast).to_contain_text("approved") # Verify database state from apps.moderation.models import PhotoSubmission pending_photo_submission.refresh_from_db() assert pending_photo_submission.status == "APPROVED" def test_photo_submission_reject_transition( self, mod_page: Page, pending_photo_submission, live_server ): """Test rejecting a PhotoSubmission as a moderator.""" mod_page.goto(f"{live_server.url}/moderation/dashboard/") mod_page.wait_for_load_state("networkidle") # Click on Photos tab if it exists photos_tab = mod_page.get_by_role("tab", name="Photos") if photos_tab.is_visible(): photos_tab.click() # Find the photo submission row submission_row = mod_page.locator( f'[data-photo-submission-id="{pending_photo_submission.pk}"]' ) if not submission_row.is_visible(): pytest.skip("Photo submission not visible in dashboard") # Click reject button reject_btn = submission_row.get_by_role("button", name="Reject") mod_page.on("dialog", lambda dialog: dialog.accept()) reject_btn.click() # Verify toast notification toast = mod_page.locator('[data-toast]') expect(toast).to_be_visible(timeout=5000) expect(toast).to_contain_text("rejected") # Verify database state from apps.moderation.models import PhotoSubmission pending_photo_submission.refresh_from_db() assert pending_photo_submission.status == "REJECTED" class TestModerationQueueTransitions: """Tests for ModerationQueue FSM transitions via HTMX.""" @pytest.fixture def pending_queue_item(self, db): """Create a pending ModerationQueue item for testing.""" from django.contrib.auth import get_user_model from apps.moderation.models import ModerationQueue User = get_user_model() user, _ = User.objects.get_or_create( username="testflagger", defaults={"email": "testflagger@example.com"} ) queue_item = ModerationQueue.objects.create( item_type="CONTENT_REVIEW", status="PENDING", priority="MEDIUM", title="E2E Test Queue Item", description="Queue item for E2E testing", flagged_by=user ) yield queue_item queue_item.delete() def test_moderation_queue_start_transition( self, mod_page: Page, pending_queue_item, live_server ): """Test starting work on a ModerationQueue item.""" mod_page.goto(f"{live_server.url}/moderation/queue/") mod_page.wait_for_load_state("networkidle") # Find the queue item row queue_row = mod_page.locator(f'[data-queue-id="{pending_queue_item.pk}"]') if not queue_row.is_visible(): pytest.skip("Queue item not visible") # Click start button start_btn = queue_row.get_by_role("button", name="Start") start_btn.click() # Verify status updated to IN_PROGRESS status_badge = queue_row.locator('[data-status-badge]') expect(status_badge).to_contain_text("In Progress", timeout=5000) # Verify database state from apps.moderation.models import ModerationQueue pending_queue_item.refresh_from_db() assert pending_queue_item.status == "IN_PROGRESS" def test_moderation_queue_complete_transition( self, mod_page: Page, pending_queue_item, live_server ): """Test completing a ModerationQueue item.""" # First set status to IN_PROGRESS pending_queue_item.status = "IN_PROGRESS" pending_queue_item.save() mod_page.goto(f"{live_server.url}/moderation/queue/") mod_page.wait_for_load_state("networkidle") queue_row = mod_page.locator(f'[data-queue-id="{pending_queue_item.pk}"]') if not queue_row.is_visible(): pytest.skip("Queue item not visible") # Click complete button complete_btn = queue_row.get_by_role("button", name="Complete") mod_page.on("dialog", lambda dialog: dialog.accept()) complete_btn.click() # Verify toast and status toast = mod_page.locator('[data-toast]') expect(toast).to_be_visible(timeout=5000) from apps.moderation.models import ModerationQueue pending_queue_item.refresh_from_db() assert pending_queue_item.status == "COMPLETED" class TestBulkOperationTransitions: """Tests for BulkOperation FSM transitions via HTMX.""" @pytest.fixture def pending_bulk_operation(self, db): """Create a pending BulkOperation for testing.""" from django.contrib.auth import get_user_model from apps.moderation.models import BulkOperation User = get_user_model() user, _ = User.objects.get_or_create( username="testadmin", defaults={"email": "testadmin@example.com", "is_staff": True} ) operation = BulkOperation.objects.create( operation_type="IMPORT", status="PENDING", priority="MEDIUM", description="E2E Test Bulk Operation", parameters={"test": True}, created_by=user, total_items=10 ) yield operation operation.delete() def test_bulk_operation_cancel_transition( self, mod_page: Page, pending_bulk_operation, live_server ): """Test canceling a BulkOperation.""" mod_page.goto(f"{live_server.url}/moderation/bulk-operations/") mod_page.wait_for_load_state("networkidle") # Find the operation row operation_row = mod_page.locator( f'[data-bulk-operation-id="{pending_bulk_operation.pk}"]' ) if not operation_row.is_visible(): pytest.skip("Bulk operation not visible") # Click cancel button cancel_btn = operation_row.get_by_role("button", name="Cancel") mod_page.on("dialog", lambda dialog: dialog.accept()) cancel_btn.click() # Verify toast toast = mod_page.locator('[data-toast]') expect(toast).to_be_visible(timeout=5000) expect(toast).to_contain_text("cancel") # Verify database state from apps.moderation.models import BulkOperation pending_bulk_operation.refresh_from_db() assert pending_bulk_operation.status == "CANCELLED" class TestTransitionLoadingStates: """Tests for loading indicators during FSM transitions.""" def test_loading_indicator_appears_during_transition( self, mod_page: Page, pending_submission, live_server ): """Verify loading spinner appears during HTMX transition.""" mod_page.goto(f"{live_server.url}/moderation/dashboard/") mod_page.wait_for_load_state("networkidle") submission_row = mod_page.locator( f'[data-submission-id="{pending_submission.pk}"]' ) # Get approve button and associated loading indicator approve_btn = submission_row.get_by_role("button", name="Approve") # Slow down network to see loading state mod_page.route("**/*", lambda route: route.continue_()) mod_page.on("dialog", lambda dialog: dialog.accept()) # Start transition approve_btn.click() # Check for htmx-indicator visibility (may be brief) # The indicator should become visible during the request loading_indicator = submission_row.locator('.htmx-indicator') # Wait for transition to complete toast = mod_page.locator('[data-toast]') expect(toast).to_be_visible(timeout=5000)