Files
thrillwiki_django_no_react/backend/tests/e2e/test_moderation_fsm.py
pacnpal 45d97b6e68 Add test utilities and state machine diagrams for FSM models
- Introduced reusable test utilities in `backend/tests/utils` for FSM transitions, HTMX interactions, and common scenarios.
- Added factory functions for creating test submissions, parks, rides, and photo submissions.
- Implemented assertion helpers for verifying state changes, toast notifications, and transition logs.
- Created comprehensive state machine diagrams for all FSM-enabled models in `docs/STATE_DIAGRAMS.md`, detailing states, transitions, and guard conditions.
2025-12-22 08:55:39 -05:00

478 lines
16 KiB
Python

"""
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)