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:
pacnpal
2025-12-22 08:55:39 -05:00
parent b508434574
commit 45d97b6e68
71 changed files with 8608 additions and 633 deletions

View File

@@ -0,0 +1,229 @@
# FSM HTMX Browser Testing Checklist
This checklist provides manual testing steps to verify the FSM+HTMX integration
works correctly across different browsers and scenarios.
## Quick Start
Before testing, ensure:
1. Development server is running: `uv run manage.py runserver`
2. Test users exist: `uv run manage.py create_test_users`
3. Test data exists: `uv run manage.py loaddata test_data`
## Moderation Dashboard
### Edit Submissions
- [ ] **Approve submission** shows green toast and updates status badge to "Approved"
- [ ] **Reject submission** shows red toast and updates status badge to "Rejected"
- [ ] **Escalate submission** shows yellow toast and updates status badge to "Escalated"
- [ ] Confirmation dialog appears for approve/reject/escalate actions
- [ ] Loading spinner appears during transition (visible briefly)
- [ ] Row updates without full page reload
- [ ] Multiple transitions in sequence work correctly
- [ ] Transition buttons disappear after final state reached (Approved/Rejected)
- [ ] Status badge color matches state (green=approved, red=rejected, yellow=escalated)
- [ ] Toast notification auto-dismisses after ~3-5 seconds
### Photo Submissions
- [ ] **Approve photo** shows success toast and creates approved photo
- [ ] **Reject photo** shows warning toast and updates status
- [ ] Photo preview remains visible during transition
- [ ] Loading state shown while processing image approval
### Moderation Queue
- [ ] **Start** button transitions item to "In Progress"
- [ ] **Complete** button transitions item to "Completed"
- [ ] **Cancel** button transitions item to "Cancelled"
- [ ] Queue items update in real-time without refresh
- [ ] Assignment updates correctly when claiming item
### Bulk Operations
- [ ] **Cancel** pending operation shows confirmation and cancels
- [ ] Progress indicator updates during operation
- [ ] Cannot cancel completed operations (button hidden)
## Park Detail Page
### Status Transitions (as Moderator)
- [ ] **Close Temporarily** button visible for operating parks
- [ ] Status badge updates to "Temporarily Closed" (yellow)
- [ ] **Reopen** button appears after temporary closure
- [ ] Status badge updates back to "Operating" (green)
- [ ] **Close Permanently** transitions to "Permanently Closed" (red)
- [ ] **Mark as Demolished** available only from permanently closed
- [ ] **Mark as Relocated** available only from permanently closed
- [ ] Status badge updates to "Demolished" (gray) or "Relocated" (gray)
### Status Actions Section
- [ ] Available transitions update based on current state
- [ ] Buttons hidden for invalid transitions (can't reopen operating park)
- [ ] Confirmation dialogs appear for destructive transitions
- [ ] Toast notifications appear for all transitions
- [ ] Section refreshes via HTMX swap without full page reload
### Unauthorized Users
- [ ] Regular users cannot see transition buttons
- [ ] Status badge visible but not clickable for regular users
- [ ] Moderator buttons visible only to moderators
## Ride Detail Page
### Status Transitions (as Moderator)
- [ ] **Close Temporarily** button works correctly
- [ ] **Mark SBNO** transitions to "Standing But Not Operating"
- [ ] **Set Closing** transitions to "Closing" status
- [ ] Closing status with future date shows countdown (if implemented)
- [ ] **Reopen** button appears for closed/SBNO rides
- [ ] **Close Permanently** available for operating/closed_temp rides
- [ ] **Mark as Demolished** available only from permanently closed
- [ ] **Mark as Relocated** available only from permanently closed
### SBNO State
- [ ] SBNO status badge shows distinct styling (orange/amber)
- [ ] Reopen available from SBNO state
- [ ] Close Permanently available from SBNO state
## Error Handling
### Invalid Transitions
- [ ] Attempting invalid transition shows error toast (red)
- [ ] Error message is user-friendly (not technical exception)
- [ ] Database state unchanged after failed transition
- [ ] UI remains in consistent state after error
### Permission Denied
- [ ] Unauthorized transition attempt shows 403 error toast
- [ ] Error message says "Permission denied" or similar
- [ ] No partial state change occurs
### Network Errors
- [ ] Offline/network failure shows error toast
- [ ] "Connection failed" or similar message displayed
- [ ] Retry option available (if applicable)
- [ ] UI recovers gracefully from network errors
### Server Errors
- [ ] 500 errors show user-friendly message
- [ ] No raw exception/traceback shown to user
- [ ] Error toast has red/danger styling
## Loading States
- [ ] Loading spinner/indicator appears immediately on button click
- [ ] Button becomes disabled during transition
- [ ] Spinner disappears when response received
- [ ] Multiple rapid clicks don't cause duplicate requests
## Confirmation Dialogs
- [ ] Dialog appears for dangerous transitions (reject, cancel, demolish)
- [ ] Dialog shows appropriate warning message
- [ ] Clicking "Cancel" prevents transition
- [ ] Clicking "OK/Confirm" executes transition
- [ ] ESC key dismisses dialog without action
- [ ] Dialog backdrop click dismisses without action
## Cross-Browser Testing
Test all scenarios above in each browser:
### Desktop
- [ ] Chrome/Chromium (latest)
- [ ] Firefox (latest)
- [ ] Safari/WebKit (latest)
- [ ] Edge (latest)
### Mobile
- [ ] Mobile Safari (iOS 15+)
- [ ] Chrome Mobile (Android)
- [ ] Samsung Internet (Android)
### Notes
- Test with different viewport sizes (mobile, tablet, desktop)
- Verify touch interactions work on mobile
- Check that modals/dialogs are properly positioned on mobile
## Accessibility
### Keyboard Navigation
- [ ] All transition buttons focusable via Tab key
- [ ] Enter key activates focused button
- [ ] Escape key closes confirmation dialogs
- [ ] Focus indicator visible on all interactive elements
- [ ] Focus returns to appropriate element after transition
### Screen Reader
- [ ] Status changes announced to screen readers
- [ ] Toast notifications announced (aria-live region)
- [ ] Button labels are descriptive
- [ ] Loading states communicated to screen readers
### Visual
- [ ] Color contrast meets WCAG AA standards
- [ ] Status colors distinguishable without relying solely on color
- [ ] Focus indicators have sufficient contrast
- [ ] Text remains readable at 200% zoom
## Performance
- [ ] Transitions complete in under 500ms (typical)
- [ ] No visible flickering during HTMX swap
- [ ] Memory usage stable after multiple transitions
- [ ] No console errors during normal operation
## Test Data Reset
After testing, reset test data:
```bash
uv run manage.py flush_test_data
uv run manage.py loaddata test_data
```
## Reporting Issues
When reporting issues:
1. Note browser and version
2. Include console errors (if any)
3. Describe expected vs actual behavior
4. Include network request/response details
5. Screenshot or video if possible
## Automated Testing
Run automated E2E tests to complement manual testing:
```bash
# Run all FSM E2E tests
pytest backend/tests/e2e/test_moderation_fsm.py
pytest backend/tests/e2e/test_park_ride_fsm.py
pytest backend/tests/e2e/test_fsm_permissions.py
pytest backend/tests/e2e/test_fsm_error_handling.py
# Run with specific browser
pytest --browser firefox backend/tests/e2e/
# Run with headed mode (visible browser)
pytest --headed backend/tests/e2e/
# Run integration tests (faster, no browser)
pytest backend/tests/integration/test_fsm_transition_view.py
```

View File

@@ -34,10 +34,10 @@ def setup_page(page: Page):
@pytest.fixture
def auth_page(page: Page):
def auth_page(page: Page, live_server):
"""Fixture for authenticated page"""
# Login
page.goto("http://localhost:8000/accounts/login/")
# Login using live_server URL
page.goto(f"{live_server.url}/accounts/login/")
page.get_by_label("Username").fill("testuser")
page.get_by_label("Password").fill("testpass123")
page.get_by_role("button", name="Sign In").click()
@@ -46,10 +46,10 @@ def auth_page(page: Page):
@pytest.fixture
def mod_page(page: Page):
def mod_page(page: Page, live_server):
"""Fixture for moderator page"""
# Login as moderator
page.goto("http://localhost:8000/accounts/login/")
# Login as moderator using live_server URL
page.goto(f"{live_server.url}/accounts/login/")
page.get_by_label("Username").fill("moderator")
page.get_by_label("Password").fill("modpass123")
page.get_by_role("button", name="Sign In").click()
@@ -58,10 +58,10 @@ def mod_page(page: Page):
@pytest.fixture
def test_park(auth_page: Page):
def test_park(auth_page: Page, live_server):
"""Fixture for test park"""
# Create test park
auth_page.goto("http://localhost:8000/parks/create/")
# Create test park using live_server URL
auth_page.goto(f"{live_server.url}/parks/create/")
auth_page.get_by_label("Name").fill("Test Park")
auth_page.get_by_label("Location").fill("Orlando, FL")
auth_page.get_by_label("Description").fill("A test theme park")
@@ -72,10 +72,10 @@ def test_park(auth_page: Page):
@pytest.fixture
def test_ride(test_park: Page):
def test_ride(test_park: Page, live_server):
"""Fixture for test ride"""
# Create test ride
test_park.goto("http://localhost:8000/rides/create/")
# Create test ride using live_server URL
test_park.goto(f"{live_server.url}/rides/create/")
test_park.get_by_label("Name").fill("Test Ride")
test_park.get_by_label("Park").select_option("Test Park")
test_park.get_by_label("Type").select_option("Roller Coaster")
@@ -87,10 +87,10 @@ def test_ride(test_park: Page):
@pytest.fixture
def test_review(test_park: Page):
def test_review(test_park: Page, live_server):
"""Fixture for test review"""
# Create test review
test_park.goto("http://localhost:8000/parks/test-park/")
# Create test review using live_server URL
test_park.goto(f"{live_server.url}/parks/test-park/")
test_park.get_by_role("tab", name="Reviews").click()
test_park.get_by_role("button", name="Write Review").click()
test_park.get_by_label("Rating").select_option("5")
@@ -99,3 +99,310 @@ def test_review(test_park: Page):
test_park.get_by_role("button", name="Submit Review").click()
yield test_park
# =============================================================================
# FSM Testing Fixtures
# =============================================================================
@pytest.fixture
def admin_page(page: Page, live_server):
"""Fixture for admin/superuser page"""
# Login as admin using live_server URL
page.goto(f"{live_server.url}/accounts/login/")
page.get_by_label("Username").fill("admin")
page.get_by_label("Password").fill("adminpass123")
page.get_by_role("button", name="Sign In").click()
yield page
@pytest.fixture
def submission_pending(db):
"""Create a pending EditSubmission for FSM testing."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
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="fsm_test_submitter",
defaults={"email": "fsm_test@example.com"}
)
user.set_password("testpass123")
user.save()
# Get a park
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": "FSM test submission"},
reason="FSM e2e test",
status="PENDING"
)
yield submission
# Cleanup
try:
submission.delete()
except Exception:
pass
@pytest.fixture
def submission_approved(db):
"""Create an approved EditSubmission for FSM testing."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
user, _ = User.objects.get_or_create(
username="fsm_test_submitter_approved",
defaults={"email": "fsm_approved@example.com"}
)
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": "Already approved"},
reason="FSM approved test",
status="APPROVED"
)
yield submission
try:
submission.delete()
except Exception:
pass
@pytest.fixture
def park_operating(db):
"""Create an operating Park for FSM testing."""
from tests.factories import ParkFactory
park = ParkFactory(
name="FSM Test Park Operating",
slug="fsm-test-park-operating",
status="OPERATING"
)
yield park
@pytest.fixture
def park_closed_temp(db):
"""Create a temporarily closed Park for FSM testing."""
from tests.factories import ParkFactory
park = ParkFactory(
name="FSM Test Park Closed Temp",
slug="fsm-test-park-closed-temp",
status="CLOSED_TEMP"
)
yield park
@pytest.fixture
def park_closed_perm(db):
"""Create a permanently closed Park for FSM testing."""
from tests.factories import ParkFactory
from datetime import date, timedelta
park = ParkFactory(
name="FSM Test Park Closed Perm",
slug="fsm-test-park-closed-perm",
status="CLOSED_PERM",
closing_date=date.today() - timedelta(days=365)
)
yield park
@pytest.fixture
def ride_operating(db, park_operating):
"""Create an operating Ride for FSM testing."""
from tests.factories import RideFactory
ride = RideFactory(
name="FSM Test Ride Operating",
slug="fsm-test-ride-operating",
park=park_operating,
status="OPERATING"
)
yield ride
@pytest.fixture
def ride_sbno(db, park_operating):
"""Create an SBNO Ride for FSM testing."""
from tests.factories import RideFactory
ride = RideFactory(
name="FSM Test Ride SBNO",
slug="fsm-test-ride-sbno",
park=park_operating,
status="SBNO"
)
yield ride
@pytest.fixture
def ride_closed_perm(db, park_operating):
"""Create a permanently closed Ride for FSM testing."""
from tests.factories import RideFactory
from datetime import date, timedelta
ride = RideFactory(
name="FSM Test Ride Closed Perm",
slug="fsm-test-ride-closed-perm",
park=park_operating,
status="CLOSED_PERM",
closing_date=date.today() - timedelta(days=365)
)
yield ride
@pytest.fixture
def queue_item_pending(db):
"""Create a pending ModerationQueue item for FSM 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="fsm_queue_flagger",
defaults={"email": "fsm_queue@example.com"}
)
queue_item = ModerationQueue.objects.create(
item_type="CONTENT_REVIEW",
status="PENDING",
priority="MEDIUM",
title="FSM Test Queue Item",
description="Queue item for FSM e2e testing",
flagged_by=user
)
yield queue_item
try:
queue_item.delete()
except Exception:
pass
@pytest.fixture
def bulk_operation_pending(db):
"""Create a pending BulkOperation for FSM 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="fsm_bulk_creator",
defaults={"email": "fsm_bulk@example.com", "is_staff": True}
)
operation = BulkOperation.objects.create(
operation_type="IMPORT",
status="PENDING",
priority="MEDIUM",
description="FSM Test Bulk Operation",
parameters={"test": True},
created_by=user,
total_items=10
)
yield operation
try:
operation.delete()
except Exception:
pass
# =============================================================================
# Helper Fixtures
# =============================================================================
@pytest.fixture
def live_server(live_server_url):
"""Provide the live server URL for tests.
Note: This fixture is provided by pytest-django. The live_server_url
fixture provides the URL as a string.
"""
class LiveServer:
url = live_server_url
return LiveServer()
@pytest.fixture
def moderator_user(db):
"""Get or create a moderator user for testing."""
from django.contrib.auth import get_user_model
User = get_user_model()
user, _ = User.objects.get_or_create(
username="moderator",
defaults={
"email": "moderator@example.com",
"is_staff": True
}
)
user.set_password("modpass123")
user.save()
return user
@pytest.fixture
def regular_user(db):
"""Get or create a regular user for testing."""
from django.contrib.auth import get_user_model
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testuser",
defaults={"email": "testuser@example.com"}
)
user.set_password("testpass123")
user.save()
return user

View File

@@ -0,0 +1,668 @@
"""
E2E Tests for FSM Error Handling and Loading States
Tests error scenarios and loading indicators during FSM transitions:
- Invalid transitions show error toast
- Loading indicator appears during transition
- Network error shows error toast
- Validation error shows user-friendly message
- Confirm dialog appears for dangerous transitions
- Cancel confirm dialog prevents transition
These tests verify:
- Error toast appears with appropriate message
- Loading spinner appears during requests
- Network errors are handled gracefully
- Confirmation dialogs work correctly
- User-friendly error messages are displayed
"""
import pytest
from playwright.sync_api import Page, expect
class TestInvalidTransitionErrors:
"""Tests for error handling when attempting invalid transitions."""
def test_invalid_transition_shows_error_toast(
self, mod_page: Page, live_server, db
):
"""Test that attempting an invalid transition shows an error toast."""
from apps.parks.models import Park
# Get an operating park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Attempt an invalid transition via direct API call
# For example, trying to reopen an already operating park
response = mod_page.evaluate(f"""
async () => {{
const response = await fetch('/core/fsm/parks/park/{park.pk}/transition/transition_to_operating/', {{
method: 'POST',
headers: {{
'HX-Request': 'true',
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
}},
credentials: 'include'
}});
return {{
status: response.status,
hxTrigger: response.headers.get('HX-Trigger')
}};
}}
""")
# Should return error status (400)
if response:
assert response.get('status') in [400, 403]
# Check for error toast in HX-Trigger header
hx_trigger = response.get('hxTrigger')
if hx_trigger:
assert 'showToast' in hx_trigger
assert 'error' in hx_trigger.lower()
def test_already_transitioned_shows_error(
self, mod_page: Page, live_server, db
):
"""Test that trying to approve an already-approved submission shows error."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
# Create an already-approved submission
user, _ = User.objects.get_or_create(
username="testsubmitter2",
defaults={"email": "testsubmitter2@example.com"}
)
park = Park.objects.first()
if not park:
pytest.skip("No park available")
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": "Already approved"},
reason="Already approved test",
status="APPROVED" # Already approved
)
try:
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
# Try to approve again via direct API call
response = mod_page.evaluate(f"""
async () => {{
const response = await fetch('/core/fsm/moderation/editsubmission/{submission.pk}/transition/transition_to_approved/', {{
method: 'POST',
headers: {{
'HX-Request': 'true',
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
}},
credentials: 'include'
}});
return {{
status: response.status,
hxTrigger: response.headers.get('HX-Trigger')
}};
}}
""")
# Should return error status
if response:
assert response.get('status') in [400, 403]
finally:
submission.delete()
def test_nonexistent_transition_shows_error(
self, mod_page: Page, live_server, db
):
"""Test that requesting a non-existent transition shows error."""
from apps.parks.models import Park
park = Park.objects.first()
if not park:
pytest.skip("No park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Try to call a non-existent transition
response = mod_page.evaluate(f"""
async () => {{
const response = await fetch('/core/fsm/parks/park/{park.pk}/transition/nonexistent_transition/', {{
method: 'POST',
headers: {{
'HX-Request': 'true',
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
}},
credentials: 'include'
}});
return {{
status: response.status,
hxTrigger: response.headers.get('HX-Trigger')
}};
}}
""")
# Should return error status (400 or 404)
if response:
assert response.get('status') in [400, 404]
class TestLoadingIndicators:
"""Tests for loading indicator visibility during transitions."""
def test_loading_indicator_appears_during_transition(
self, mod_page: Page, live_server, db
):
"""Verify loading spinner appears during HTMX transition."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
# Add a route to slow down the request so we can see loading state
mod_page.route("**/core/fsm/**", lambda route: (
mod_page.wait_for_timeout(500),
route.continue_()
))
# Handle confirmation dialog
mod_page.on("dialog", lambda dialog: dialog.accept())
# Click and immediately check for loading indicator
close_temp_btn.click()
# Check for htmx-indicator class or spinner
loading_indicator = mod_page.locator('.htmx-indicator, .htmx-request .spinner, [class*="loading"]')
# The loading indicator should appear (may be brief)
# We wait a short time for it to appear
try:
expect(loading_indicator.first).to_be_visible(timeout=1000)
except Exception:
# Loading indicator may have already disappeared if response was fast
pass
# Wait for transition to complete
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
def test_button_disabled_during_transition(
self, mod_page: Page, live_server, db
):
"""Test that transition button is disabled during request."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
# Add a route to slow down the request
mod_page.route("**/core/fsm/**", lambda route: (
mod_page.wait_for_timeout(1000),
route.continue_()
))
mod_page.on("dialog", lambda dialog: dialog.accept())
# Click button
close_temp_btn.click()
# Check if button becomes disabled or has htmx-request class
expect(close_temp_btn).to_have_attribute("disabled", "", timeout=500)
class TestNetworkErrorHandling:
"""Tests for handling network errors during transitions."""
def test_network_error_shows_error_toast(
self, mod_page: Page, live_server, db
):
"""Test that network errors show appropriate error toast."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Abort network requests to simulate network error
mod_page.route("**/core/fsm/**", lambda route: route.abort("failed"))
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
mod_page.on("dialog", lambda dialog: dialog.accept())
# Click button - should trigger network error
close_temp_btn.click()
# HTMX should show error indication
# Check for error toast or htmx error event
error_indicator = mod_page.locator('[data-toast].error, .htmx-error, [class*="error"]')
# May show as toast or inline error
try:
expect(error_indicator.first).to_be_visible(timeout=5000)
except Exception:
# Error may be handled differently
pass
# Verify database state was NOT changed
park.refresh_from_db()
assert park.status == "OPERATING"
def test_server_error_shows_user_friendly_message(
self, mod_page: Page, live_server, db
):
"""Test that server errors show user-friendly messages."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Return 500 error to simulate server error
mod_page.route("**/core/fsm/**", lambda route: route.fulfill(
status=500,
headers={"HX-Trigger": '{"showToast": {"message": "An unexpected error occurred", "type": "error"}}'},
body=""
))
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
mod_page.on("dialog", lambda dialog: dialog.accept())
close_temp_btn.click()
# Should show user-friendly error message
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Should not show technical error details to user
expect(toast).not_to_contain_text("Traceback")
expect(toast).not_to_contain_text("Exception")
class TestConfirmationDialogs:
"""Tests for confirmation dialogs on dangerous transitions."""
def test_confirm_dialog_appears_for_reject_transition(
self, mod_page: Page, live_server, db
):
"""Test that confirmation dialog appears for reject transition."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testsubmitter3",
defaults={"email": "testsubmitter3@example.com"}
)
park = Park.objects.first()
if not park:
pytest.skip("No park available")
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": "Confirm dialog test"},
reason="Confirm dialog test",
status="PENDING"
)
dialog_shown = {"shown": False}
def handle_dialog(dialog):
dialog_shown["shown"] = True
assert "reject" in dialog.message.lower() or "sure" in dialog.message.lower()
dialog.dismiss() # Cancel the action
try:
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
mod_page.on("dialog", handle_dialog)
submission_row = mod_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
if submission_row.is_visible():
reject_btn = submission_row.get_by_role("button", name="Reject")
if reject_btn.is_visible():
reject_btn.click()
# Give time for dialog to appear
mod_page.wait_for_timeout(500)
# Verify dialog was shown
assert dialog_shown["shown"], "Confirmation dialog should have been shown"
finally:
submission.delete()
def test_cancel_confirm_dialog_prevents_transition(
self, mod_page: Page, live_server, db
):
"""Test that canceling the confirmation dialog prevents the transition."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testsubmitter4",
defaults={"email": "testsubmitter4@example.com"}
)
park = Park.objects.first()
if not park:
pytest.skip("No park available")
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": "Cancel confirm test"},
reason="Cancel confirm test",
status="PENDING"
)
try:
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
# Dismiss (cancel) the dialog
mod_page.on("dialog", lambda dialog: dialog.dismiss())
submission_row = mod_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
if submission_row.is_visible():
reject_btn = submission_row.get_by_role("button", name="Reject")
if reject_btn.is_visible():
reject_btn.click()
# Wait a moment
mod_page.wait_for_timeout(500)
# Verify submission status was NOT changed
submission.refresh_from_db()
assert submission.status == "PENDING"
finally:
submission.delete()
def test_accept_confirm_dialog_executes_transition(
self, mod_page: Page, live_server, db
):
"""Test that accepting the confirmation dialog executes the transition."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testsubmitter5",
defaults={"email": "testsubmitter5@example.com"}
)
park = Park.objects.first()
if not park:
pytest.skip("No park available")
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": "Accept confirm test"},
reason="Accept confirm test",
status="PENDING"
)
try:
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
# Accept the dialog
mod_page.on("dialog", lambda dialog: dialog.accept())
submission_row = mod_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
if submission_row.is_visible():
reject_btn = submission_row.get_by_role("button", name="Reject")
if reject_btn.is_visible():
reject_btn.click()
# Wait for transition to complete
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify submission status WAS changed
submission.refresh_from_db()
assert submission.status == "REJECTED"
finally:
submission.delete()
class TestValidationErrors:
"""Tests for validation error handling."""
def test_validation_error_shows_specific_message(
self, mod_page: Page, live_server, db
):
"""Test that validation errors show specific error messages."""
# This test depends on having transitions that require additional data
# For example, a transition that requires a reason field
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Some transitions might require additional data (like closing_date for close_permanently)
# If validation fails, it should show a specific message
# This is a placeholder test - actual behavior depends on FSM configuration
# The test verifies that validation errors are handled gracefully
pass
class TestToastNotificationBehavior:
"""Tests for toast notification appearance and behavior."""
def test_success_toast_auto_dismisses(
self, mod_page: Page, live_server, db
):
"""Test that success toast auto-dismisses after timeout."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
mod_page.on("dialog", lambda dialog: dialog.accept())
close_temp_btn.click()
# Toast should appear
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Toast should auto-dismiss after timeout (typically 3-5 seconds)
# Wait for auto-dismiss
expect(toast).not_to_be_visible(timeout=10000)
def test_error_toast_has_correct_styling(
self, mod_page: Page, live_server, db
):
"""Test that error toast has correct red/danger styling."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Simulate an error response
mod_page.route("**/core/fsm/**", lambda route: route.fulfill(
status=400,
headers={
"HX-Trigger": '{"showToast": {"message": "Test error message", "type": "error"}}'
},
body=""
))
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
mod_page.on("dialog", lambda dialog: dialog.accept())
close_temp_btn.click()
# Error toast should appear with error styling
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Should have error/danger styling (red)
expect(toast).to_have_class(/error|danger|bg-red|text-red/)
def test_success_toast_has_correct_styling(
self, mod_page: Page, live_server, db
):
"""Test that success toast has correct green/success styling."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
# Reset any previous state
park.status = "OPERATING"
park.save()
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
pytest.skip("Close Temporarily button not visible")
mod_page.on("dialog", lambda dialog: dialog.accept())
close_temp_btn.click()
# Success toast should appear with success styling
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Should have success styling (green)
expect(toast).to_have_class(/success|bg-green|text-green/)

View File

@@ -0,0 +1,505 @@
"""
E2E Tests for FSM Permission Guards
Tests that unauthorized users cannot execute FSM transitions:
- Unauthenticated users cannot see transition buttons
- Regular users cannot approve submissions
- Regular users cannot change park/ride status
- Moderators can approve but not admin-only transitions
- Transition buttons hidden when not allowed
These tests verify:
- Transition buttons are NOT visible for unauthorized users
- Direct POST requests return 403 Forbidden
- Database state does NOT change after failed transition attempt
- Error toast displays "Permission denied" message
"""
import pytest
from playwright.sync_api import Page, expect
class TestUnauthenticatedUserPermissions:
"""Tests for unauthenticated user permission guards."""
def test_unauthenticated_user_cannot_see_moderation_dashboard(
self, page: Page, live_server
):
"""Test that unauthenticated users are redirected from moderation dashboard."""
# Navigate to moderation dashboard without logging in
response = page.goto(f"{live_server.url}/moderation/dashboard/")
# Should be redirected to login page or see access denied
# Check URL contains login or access denied
current_url = page.url
assert "login" in current_url or "denied" in current_url or response.status == 403
def test_unauthenticated_user_cannot_see_transition_buttons(
self, page: Page, live_server, db
):
"""Test that unauthenticated users cannot see transition buttons on park detail."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
page.goto(f"{live_server.url}/parks/{park.slug}/")
page.wait_for_load_state("networkidle")
# Status action buttons should NOT be visible
status_actions = page.locator('[data-park-status-actions]')
# Either the section doesn't exist or the buttons are not there
if status_actions.is_visible():
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
expect(close_temp_btn).not_to_be_visible()
def test_unauthenticated_direct_post_returns_403(
self, page: Page, live_server, db
):
"""Test that direct POST to FSM endpoint returns 403 for unauthenticated user."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
# Attempt to POST directly to FSM transition endpoint
response = page.request.post(
f"{live_server.url}/core/fsm/parks/park/{park.pk}/transition/transition_to_closed_temp/",
headers={"HX-Request": "true"}
)
# Should get 403 Forbidden
assert response.status == 403 or response.status == 302 # 302 redirect to login
# Verify database state did NOT change
park.refresh_from_db()
assert park.status == "OPERATING"
class TestRegularUserPermissions:
"""Tests for regular (non-moderator) user permission guards."""
def test_regular_user_cannot_approve_submission(
self, auth_page: Page, live_server, db
):
"""Test that regular users cannot approve submissions."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
# Create a pending submission
user = User.objects.filter(username="testuser").first()
if not user:
pytest.skip("Test user not found")
park = Park.objects.first()
if not park:
pytest.skip("No park available")
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": "Test change"},
reason="Permission test",
status="PENDING"
)
try:
# Navigate to moderation dashboard as regular user
auth_page.goto(f"{live_server.url}/moderation/dashboard/")
# Regular user should be redirected or denied
current_url = auth_page.url
# If somehow on dashboard, verify no approve button
if "dashboard" in current_url:
submission_row = auth_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
if submission_row.is_visible():
approve_btn = submission_row.get_by_role("button", name="Approve")
expect(approve_btn).not_to_be_visible()
# Try direct POST - should be denied
response = auth_page.request.post(
f"{live_server.url}/core/fsm/moderation/editsubmission/{submission.pk}/transition/transition_to_approved/",
headers={"HX-Request": "true"}
)
# Should be denied (403 or 302 redirect)
assert response.status in [302, 403]
# Verify database state did NOT change
submission.refresh_from_db()
assert submission.status == "PENDING"
finally:
submission.delete()
def test_regular_user_cannot_change_park_status(
self, auth_page: Page, live_server, db
):
"""Test that regular users cannot change park status."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
auth_page.wait_for_load_state("networkidle")
# Status action buttons should NOT be visible to regular user
status_actions = auth_page.locator('[data-park-status-actions]')
if status_actions.is_visible():
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
expect(close_temp_btn).not_to_be_visible()
# Try direct POST - should be denied
response = auth_page.request.post(
f"{live_server.url}/core/fsm/parks/park/{park.pk}/transition/transition_to_closed_temp/",
headers={"HX-Request": "true"}
)
# Should be denied
assert response.status in [302, 400, 403]
# Verify database state did NOT change
park.refresh_from_db()
assert park.status == "OPERATING"
def test_regular_user_cannot_change_ride_status(
self, auth_page: Page, live_server, db
):
"""Test that regular users cannot change ride status."""
from apps.rides.models import Ride
ride = Ride.objects.filter(status="OPERATING").first()
if not ride:
pytest.skip("No operating ride available")
auth_page.goto(
f"{live_server.url}/parks/{ride.park.slug}/rides/{ride.slug}/"
)
auth_page.wait_for_load_state("networkidle")
# Status action buttons should NOT be visible to regular user
status_actions = auth_page.locator('[data-ride-status-actions]')
if status_actions.is_visible():
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
expect(close_temp_btn).not_to_be_visible()
# Try direct POST - should be denied
response = auth_page.request.post(
f"{live_server.url}/core/fsm/rides/ride/{ride.pk}/transition/transition_to_closed_temp/",
headers={"HX-Request": "true"}
)
# Should be denied
assert response.status in [302, 400, 403]
# Verify database state did NOT change
ride.refresh_from_db()
assert ride.status == "OPERATING"
class TestModeratorPermissions:
"""Tests for moderator-specific permission guards."""
def test_moderator_can_approve_submission(
self, mod_page: Page, live_server, db
):
"""Test that moderators CAN see and use approve button."""
from django.contrib.auth import get_user_model
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
# Create a pending submission
user = User.objects.filter(username="testuser").first()
if not user:
user = User.objects.create_user(
username="testuser",
email="testuser@example.com",
password="testpass123"
)
park = Park.objects.first()
if not park:
pytest.skip("No park available")
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": "Test change for moderator"},
reason="Moderator permission test",
status="PENDING"
)
try:
mod_page.goto(f"{live_server.url}/moderation/dashboard/")
mod_page.wait_for_load_state("networkidle")
# Moderator should be able to see the submission
submission_row = mod_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
if submission_row.is_visible():
# Should see approve button
approve_btn = submission_row.get_by_role("button", name="Approve")
expect(approve_btn).to_be_visible()
finally:
submission.delete()
def test_moderator_can_change_park_status(
self, mod_page: Page, live_server, db
):
"""Test that moderators CAN see and use park status change buttons."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Status action buttons SHOULD be visible to moderator
status_actions = mod_page.locator('[data-park-status-actions]')
if status_actions.is_visible():
# Should see close temporarily button
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
expect(close_temp_btn).to_be_visible()
def test_moderator_cannot_access_admin_only_transitions(
self, mod_page: Page, live_server, db
):
"""Test that moderators CANNOT access admin-only transitions."""
# This test verifies that certain transitions require admin privileges
# Specific transitions depend on the FSM configuration
from apps.parks.models import Park
# Get a permanently closed park for testing admin-only demolish
park = Park.objects.filter(status="CLOSED_PERM").first()
if not park:
# Create one
park = Park.objects.filter(status="OPERATING").first()
if park:
park.status = "CLOSED_PERM"
park.save()
else:
pytest.skip("No park available for testing")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Check for admin-only buttons (if any are configured)
# The specific buttons that should be hidden depend on the FSM configuration
status_actions = mod_page.locator('[data-park-status-actions]')
# If there are admin-only transitions, verify they're hidden
# This is a placeholder - actual admin-only transitions depend on configuration
admin_only_btn = status_actions.get_by_role(
"button", name="Force Delete" # Example admin-only action
)
expect(admin_only_btn).not_to_be_visible()
class TestPermissionDeniedErrorHandling:
"""Tests for error handling when permission is denied."""
def test_permission_denied_shows_error_toast(
self, auth_page: Page, live_server, db
):
"""Test that permission denied errors show appropriate toast."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
# Navigate to the page first
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
auth_page.wait_for_load_state("networkidle")
# Make the request programmatically with HTMX header
response = auth_page.evaluate("""
async () => {
const response = await fetch('/core/fsm/parks/park/""" + str(park.pk) + """/transition/transition_to_closed_temp/', {
method: 'POST',
headers: {
'HX-Request': 'true',
'Content-Type': 'application/x-www-form-urlencoded'
},
credentials: 'include'
});
return {
status: response.status,
hxTrigger: response.headers.get('HX-Trigger')
};
}
""")
# Check if error toast was triggered
if response and response.get('status') in [400, 403]:
hx_trigger = response.get('hxTrigger')
if hx_trigger:
assert 'showToast' in hx_trigger
assert 'error' in hx_trigger.lower() or 'denied' in hx_trigger.lower()
def test_database_state_unchanged_on_permission_denied(
self, auth_page: Page, live_server, db
):
"""Test that database state is unchanged when permission is denied."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
original_status = park.status
# Attempt unauthorized transition via direct fetch
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
auth_page.wait_for_load_state("networkidle")
auth_page.evaluate("""
async () => {
await fetch('/core/fsm/parks/park/""" + str(park.pk) + """/transition/transition_to_closed_temp/', {
method: 'POST',
headers: {
'HX-Request': 'true',
'Content-Type': 'application/x-www-form-urlencoded'
},
credentials: 'include'
});
}
""")
# Verify database state did NOT change
park.refresh_from_db()
assert park.status == original_status
class TestTransitionButtonVisibility:
"""Tests for correct transition button visibility based on permissions and state."""
def test_transition_button_hidden_when_state_invalid(
self, mod_page: Page, live_server, db
):
"""Test that transition buttons are hidden when the current state is invalid."""
from apps.parks.models import Park
# Get an operating park
park = Park.objects.filter(status="OPERATING").first()
if not park:
pytest.skip("No operating park available")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
# Reopen button should NOT be visible for operating park
# (can't reopen something that's already operating)
reopen_btn = status_actions.get_by_role("button", name="Reopen")
expect(reopen_btn).not_to_be_visible()
# Demolish should NOT be visible for operating park
# (can only demolish from CLOSED_PERM)
demolish_btn = status_actions.get_by_role("button", name="Mark as Demolished")
expect(demolish_btn).not_to_be_visible()
def test_correct_buttons_shown_for_closed_temp_state(
self, mod_page: Page, live_server, db
):
"""Test that correct buttons are shown for temporarily closed state."""
from apps.parks.models import Park
park = Park.objects.filter(status="CLOSED_TEMP").first()
if not park:
# Create one
park = Park.objects.filter(status="OPERATING").first()
if park:
park.status = "CLOSED_TEMP"
park.save()
else:
pytest.skip("No park available for testing")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
# Reopen button SHOULD be visible
reopen_btn = status_actions.get_by_role("button", name="Reopen")
expect(reopen_btn).to_be_visible()
# Close Temporarily should NOT be visible (already closed)
close_temp_btn = status_actions.get_by_role(
"button", name="Close Temporarily"
)
expect(close_temp_btn).not_to_be_visible()
def test_correct_buttons_shown_for_closed_perm_state(
self, mod_page: Page, live_server, db
):
"""Test that correct buttons are shown for permanently closed state."""
from apps.parks.models import Park
park = Park.objects.filter(status="CLOSED_PERM").first()
if not park:
# Create one
park = Park.objects.filter(status="OPERATING").first()
if park:
park.status = "CLOSED_PERM"
park.save()
else:
pytest.skip("No park available for testing")
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_actions = mod_page.locator('[data-park-status-actions]')
# Demolish/Relocate buttons SHOULD be visible
demolish_btn = status_actions.get_by_role("button", name="Mark as Demolished")
relocate_btn = status_actions.get_by_role("button", name="Mark as Relocated")
# At least one of these should be visible for CLOSED_PERM
visible = demolish_btn.is_visible() or relocate_btn.is_visible()
assert visible, "Expected demolish or relocate button for CLOSED_PERM state"
# Reopen should still be visible to restore to operating
reopen_btn = status_actions.get_by_role("button", name="Reopen")
# May or may not be visible depending on FSM configuration

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

View File

@@ -0,0 +1,591 @@
"""
E2E Tests for Park and Ride FSM Status Transitions
Tests the complete HTMX FSM transition flow for park and ride status changes:
- Park: close_temporarily, reopen, close_permanently, demolish, relocate
- Ride: close_temporarily, mark_sbno, close_permanently, demolish, relocate
These tests verify:
- Status badge updates in detail page header
- Available transitions update based on current state
- 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 datetime import date, timedelta
@pytest.fixture
def operating_park(db):
"""Create an operating Park for testing status transitions."""
from apps.parks.models import Park
from tests.factories import ParkFactory
# Use factory to create a complete park
park = ParkFactory(
name="E2E Test Park",
slug="e2e-test-park",
status="OPERATING"
)
yield park
# Cleanup handled by factory
@pytest.fixture
def operating_ride(db, operating_park):
"""Create an operating Ride for testing status transitions."""
from tests.factories import RideFactory
ride = RideFactory(
name="E2E Test Ride",
slug="e2e-test-ride",
park=operating_park,
status="OPERATING"
)
yield ride
class TestParkStatusTransitions:
"""Tests for Park FSM status transitions via HTMX."""
def test_park_close_temporarily_as_moderator(
self, mod_page: Page, operating_park, live_server
):
"""Test closing a park temporarily as a moderator."""
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Verify initial status badge shows Operating
status_section = mod_page.locator('[data-park-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_contain_text("Operating")
# Find and click "Close Temporarily" button
close_temp_btn = status_section.get_by_role(
"button", name="Close Temporarily"
)
if not close_temp_btn.is_visible():
# May be in a dropdown menu
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
close_temp_btn = mod_page.get_by_role(
"button", name="Close Temporarily"
)
# Handle confirmation dialog
mod_page.on("dialog", lambda dialog: dialog.accept())
close_temp_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Temporarily Closed", timeout=5000)
# Verify database state
from apps.parks.models import Park
operating_park.refresh_from_db()
assert operating_park.status == "CLOSED_TEMP"
def test_park_reopen_from_closed_temp(
self, mod_page: Page, operating_park, live_server
):
"""Test reopening a temporarily closed park."""
# First close the park temporarily
operating_park.status = "CLOSED_TEMP"
operating_park.save()
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
# Verify initial status badge shows Temporarily Closed
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_contain_text("Temporarily Closed")
# Find and click "Reopen" button
status_section = mod_page.locator('[data-park-status-actions]')
reopen_btn = status_section.get_by_role("button", name="Reopen")
mod_page.on("dialog", lambda dialog: dialog.accept())
reopen_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Operating", timeout=5000)
# Verify database state
from apps.parks.models import Park
operating_park.refresh_from_db()
assert operating_park.status == "OPERATING"
def test_park_close_permanently_as_moderator(
self, mod_page: Page, operating_park, live_server
):
"""Test closing a park permanently as a moderator."""
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-park-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Close Permanently" button
close_perm_btn = status_section.get_by_role(
"button", name="Close Permanently"
)
if not close_perm_btn.is_visible():
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
close_perm_btn = mod_page.get_by_role(
"button", name="Close Permanently"
)
mod_page.on("dialog", lambda dialog: dialog.accept())
close_perm_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Permanently Closed", timeout=5000)
# Verify database state
from apps.parks.models import Park
operating_park.refresh_from_db()
assert operating_park.status == "CLOSED_PERM"
def test_park_demolish_from_closed_perm(
self, mod_page: Page, operating_park, live_server
):
"""Test transitioning a permanently closed park to demolished."""
# Set park to permanently closed
operating_park.status = "CLOSED_PERM"
operating_park.closing_date = date.today() - timedelta(days=365)
operating_park.save()
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-park-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Mark as Demolished" button
demolish_btn = status_section.get_by_role("button", name="Mark as Demolished")
if not demolish_btn.is_visible():
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
demolish_btn = mod_page.get_by_role(
"button", name="Mark as Demolished"
)
mod_page.on("dialog", lambda dialog: dialog.accept())
demolish_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Demolished", timeout=5000)
# Verify database state
from apps.parks.models import Park
operating_park.refresh_from_db()
assert operating_park.status == "DEMOLISHED"
def test_park_available_transitions_update(
self, mod_page: Page, operating_park, live_server
):
"""Test that available transitions update based on current state."""
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-park-status-actions]')
# Operating park should have Close Temporarily and Close Permanently
expect(
status_section.get_by_role("button", name="Close Temporarily")
).to_be_visible()
# Should NOT have Reopen (not applicable for Operating state)
expect(
status_section.get_by_role("button", name="Reopen")
).not_to_be_visible()
# Now close temporarily and verify buttons change
operating_park.status = "CLOSED_TEMP"
operating_park.save()
mod_page.reload()
mod_page.wait_for_load_state("networkidle")
# Now should have Reopen button
expect(
status_section.get_by_role("button", name="Reopen")
).to_be_visible()
class TestRideStatusTransitions:
"""Tests for Ride FSM status transitions via HTMX."""
def test_ride_close_temporarily_as_moderator(
self, mod_page: Page, operating_ride, live_server
):
"""Test closing a ride temporarily as a moderator."""
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_contain_text("Operating")
# Find and click "Close Temporarily" button
close_temp_btn = status_section.get_by_role(
"button", name="Close Temporarily"
)
mod_page.on("dialog", lambda dialog: dialog.accept())
close_temp_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Temporarily Closed", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "CLOSED_TEMP"
def test_ride_mark_sbno_as_moderator(
self, mod_page: Page, operating_ride, live_server
):
"""Test marking a ride as Standing But Not Operating (SBNO)."""
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Mark SBNO" button
sbno_btn = status_section.get_by_role("button", name="Mark SBNO")
if not sbno_btn.is_visible():
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
sbno_btn = mod_page.get_by_role("button", name="Mark SBNO")
mod_page.on("dialog", lambda dialog: dialog.accept())
sbno_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("SBNO", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "SBNO"
def test_ride_reopen_from_closed_temp(
self, mod_page: Page, operating_ride, live_server
):
"""Test reopening a temporarily closed ride."""
# First close the ride temporarily
operating_ride.status = "CLOSED_TEMP"
operating_ride.save()
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Reopen" button
reopen_btn = status_section.get_by_role("button", name="Reopen")
mod_page.on("dialog", lambda dialog: dialog.accept())
reopen_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Operating", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "OPERATING"
def test_ride_close_permanently_as_moderator(
self, mod_page: Page, operating_ride, live_server
):
"""Test closing a ride permanently as a moderator."""
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Close Permanently" button
close_perm_btn = status_section.get_by_role(
"button", name="Close Permanently"
)
if not close_perm_btn.is_visible():
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
close_perm_btn = mod_page.get_by_role(
"button", name="Close Permanently"
)
mod_page.on("dialog", lambda dialog: dialog.accept())
close_perm_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Permanently Closed", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "CLOSED_PERM"
def test_ride_demolish_from_closed_perm(
self, mod_page: Page, operating_ride, live_server
):
"""Test transitioning a permanently closed ride to demolished."""
# Set ride to permanently closed
operating_ride.status = "CLOSED_PERM"
operating_ride.closing_date = date.today() - timedelta(days=365)
operating_ride.save()
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Mark as Demolished" button
demolish_btn = status_section.get_by_role(
"button", name="Mark as Demolished"
)
if not demolish_btn.is_visible():
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
demolish_btn = mod_page.get_by_role(
"button", name="Mark as Demolished"
)
mod_page.on("dialog", lambda dialog: dialog.accept())
demolish_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Demolished", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "DEMOLISHED"
def test_ride_relocate_from_closed_perm(
self, mod_page: Page, operating_ride, live_server
):
"""Test transitioning a permanently closed ride to relocated."""
# Set ride to permanently closed
operating_ride.status = "CLOSED_PERM"
operating_ride.closing_date = date.today() - timedelta(days=365)
operating_ride.save()
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
status_badge = mod_page.locator('[data-status-badge]')
# Find and click "Mark as Relocated" button
relocate_btn = status_section.get_by_role(
"button", name="Mark as Relocated"
)
if not relocate_btn.is_visible():
actions_dropdown = status_section.locator('[data-actions-dropdown]')
if actions_dropdown.is_visible():
actions_dropdown.click()
relocate_btn = mod_page.get_by_role(
"button", name="Mark as Relocated"
)
mod_page.on("dialog", lambda dialog: dialog.accept())
relocate_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
expect(status_badge).to_contain_text("Relocated", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "RELOCATED"
class TestRideClosingWorkflow:
"""Tests for the special CLOSING status workflow with automatic transitions."""
def test_ride_set_closing_with_future_date(
self, mod_page: Page, operating_ride, live_server
):
"""Test setting a ride to CLOSING status with a future closing date."""
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
status_section = mod_page.locator('[data-ride-status-actions]')
# Find and click "Set Closing" button
set_closing_btn = status_section.get_by_role(
"button", name="Set Closing"
)
if set_closing_btn.is_visible():
mod_page.on("dialog", lambda dialog: dialog.accept())
set_closing_btn.click()
# Verify status badge updated
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_contain_text("Closing", timeout=5000)
# Verify database state
from apps.rides.models import Ride
operating_ride.refresh_from_db()
assert operating_ride.status == "CLOSING"
else:
pytest.skip("Set Closing button not available")
def test_ride_closing_shows_countdown(
self, mod_page: Page, operating_ride, live_server
):
"""Test that a ride in CLOSING status shows a countdown to closing date."""
# Set ride to CLOSING with future date
future_date = date.today() + timedelta(days=30)
operating_ride.status = "CLOSING"
operating_ride.closing_date = future_date
operating_ride.save()
mod_page.goto(
f"{live_server.url}/parks/{operating_ride.park.slug}/rides/{operating_ride.slug}/"
)
mod_page.wait_for_load_state("networkidle")
# Verify closing countdown is displayed
closing_info = mod_page.locator('[data-closing-countdown]')
if closing_info.is_visible():
expect(closing_info).to_contain_text("30")
else:
# May just show the status badge
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_contain_text("Closing")
class TestStatusBadgeStyling:
"""Tests for correct status badge styling based on state."""
def test_operating_status_badge_style(
self, mod_page: Page, operating_park, live_server
):
"""Test that Operating status has correct green styling."""
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_have_class(/bg-green|text-green|success/)
def test_closed_temp_status_badge_style(
self, mod_page: Page, operating_park, live_server
):
"""Test that Temporarily Closed status has correct yellow/warning styling."""
operating_park.status = "CLOSED_TEMP"
operating_park.save()
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_have_class(/bg-yellow|text-yellow|warning/)
def test_closed_perm_status_badge_style(
self, mod_page: Page, operating_park, live_server
):
"""Test that Permanently Closed status has correct red/danger styling."""
operating_park.status = "CLOSED_PERM"
operating_park.save()
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_have_class(/bg-red|text-red|danger/)
def test_demolished_status_badge_style(
self, mod_page: Page, operating_park, live_server
):
"""Test that Demolished status has correct gray styling."""
operating_park.status = "DEMOLISHED"
operating_park.save()
mod_page.goto(f"{live_server.url}/parks/{operating_park.slug}/")
mod_page.wait_for_load_state("networkidle")
status_badge = mod_page.locator('[data-status-badge]')
expect(status_badge).to_have_class(/bg-gray|text-gray|muted/)