mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 14:51:09 -05:00
- 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.
478 lines
16 KiB
Python
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)
|