mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 23:11:08 -05:00
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.
This commit is contained in:
477
backend/tests/e2e/test_moderation_fsm.py
Normal file
477
backend/tests/e2e/test_moderation_fsm.py
Normal file
@@ -0,0 +1,477 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user