feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -25,15 +25,18 @@ def setup_test_data(django_db_setup, django_db_blocker):
test_users = [
{"username": "testuser", "email": "testuser@example.com", "password": "testpass123"},
{"username": "moderator", "email": "moderator@example.com", "password": "modpass123", "is_staff": True},
{"username": "admin", "email": "admin@example.com", "password": "adminpass123", "is_staff": True, "is_superuser": True},
{
"username": "admin",
"email": "admin@example.com",
"password": "adminpass123",
"is_staff": True,
"is_superuser": True,
},
]
for user_data in test_users:
password = user_data.pop("password")
user, created = User.objects.get_or_create(
username=user_data["username"],
defaults=user_data
)
user, created = User.objects.get_or_create(username=user_data["username"], defaults=user_data)
if created:
user.set_password(password)
user.save()
@@ -55,11 +58,7 @@ def setup_page(page: Page):
# Listen for console errors
page.on(
"console",
lambda msg: (
print(f"Browser console {msg.type}: {msg.text}")
if msg.type == "error"
else None
),
lambda msg: (print(f"Browser console {msg.type}: {msg.text}") if msg.type == "error" else None),
)
yield page
@@ -98,37 +97,356 @@ def test_images():
"""
# Minimal valid JPEG image (1x1 red pixel)
# This is a valid JPEG that any image library will accept
jpeg_bytes = bytes([
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09,
0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29,
0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32,
0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01,
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00,
0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03,
0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D,
0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06,
0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72,
0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28,
0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45,
0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59,
0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3,
0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6,
0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9,
0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4,
0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01,
0x00, 0x00, 0x3F, 0x00, 0xFB, 0xD5, 0xDB, 0x20, 0xA8, 0xBA, 0xAE, 0xAF,
0xDA, 0xAD, 0x28, 0xA6, 0x02, 0x8A, 0x28, 0x03, 0xFF, 0xD9
])
jpeg_bytes = bytes(
[
0xFF,
0xD8,
0xFF,
0xE0,
0x00,
0x10,
0x4A,
0x46,
0x49,
0x46,
0x00,
0x01,
0x01,
0x00,
0x00,
0x01,
0x00,
0x01,
0x00,
0x00,
0xFF,
0xDB,
0x00,
0x43,
0x00,
0x08,
0x06,
0x06,
0x07,
0x06,
0x05,
0x08,
0x07,
0x07,
0x07,
0x09,
0x09,
0x08,
0x0A,
0x0C,
0x14,
0x0D,
0x0C,
0x0B,
0x0B,
0x0C,
0x19,
0x12,
0x13,
0x0F,
0x14,
0x1D,
0x1A,
0x1F,
0x1E,
0x1D,
0x1A,
0x1C,
0x1C,
0x20,
0x24,
0x2E,
0x27,
0x20,
0x22,
0x2C,
0x23,
0x1C,
0x1C,
0x28,
0x37,
0x29,
0x2C,
0x30,
0x31,
0x34,
0x34,
0x34,
0x1F,
0x27,
0x39,
0x3D,
0x38,
0x32,
0x3C,
0x2E,
0x33,
0x34,
0x32,
0xFF,
0xC0,
0x00,
0x0B,
0x08,
0x00,
0x01,
0x00,
0x01,
0x01,
0x01,
0x11,
0x00,
0xFF,
0xC4,
0x00,
0x1F,
0x00,
0x00,
0x01,
0x05,
0x01,
0x01,
0x01,
0x01,
0x01,
0x01,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x01,
0x02,
0x03,
0x04,
0x05,
0x06,
0x07,
0x08,
0x09,
0x0A,
0x0B,
0xFF,
0xC4,
0x00,
0xB5,
0x10,
0x00,
0x02,
0x01,
0x03,
0x03,
0x02,
0x04,
0x03,
0x05,
0x05,
0x04,
0x04,
0x00,
0x00,
0x01,
0x7D,
0x01,
0x02,
0x03,
0x00,
0x04,
0x11,
0x05,
0x12,
0x21,
0x31,
0x41,
0x06,
0x13,
0x51,
0x61,
0x07,
0x22,
0x71,
0x14,
0x32,
0x81,
0x91,
0xA1,
0x08,
0x23,
0x42,
0xB1,
0xC1,
0x15,
0x52,
0xD1,
0xF0,
0x24,
0x33,
0x62,
0x72,
0x82,
0x09,
0x0A,
0x16,
0x17,
0x18,
0x19,
0x1A,
0x25,
0x26,
0x27,
0x28,
0x29,
0x2A,
0x34,
0x35,
0x36,
0x37,
0x38,
0x39,
0x3A,
0x43,
0x44,
0x45,
0x46,
0x47,
0x48,
0x49,
0x4A,
0x53,
0x54,
0x55,
0x56,
0x57,
0x58,
0x59,
0x5A,
0x63,
0x64,
0x65,
0x66,
0x67,
0x68,
0x69,
0x6A,
0x73,
0x74,
0x75,
0x76,
0x77,
0x78,
0x79,
0x7A,
0x83,
0x84,
0x85,
0x86,
0x87,
0x88,
0x89,
0x8A,
0x92,
0x93,
0x94,
0x95,
0x96,
0x97,
0x98,
0x99,
0x9A,
0xA2,
0xA3,
0xA4,
0xA5,
0xA6,
0xA7,
0xA8,
0xA9,
0xAA,
0xB2,
0xB3,
0xB4,
0xB5,
0xB6,
0xB7,
0xB8,
0xB9,
0xBA,
0xC2,
0xC3,
0xC4,
0xC5,
0xC6,
0xC7,
0xC8,
0xC9,
0xCA,
0xD2,
0xD3,
0xD4,
0xD5,
0xD6,
0xD7,
0xD8,
0xD9,
0xDA,
0xE1,
0xE2,
0xE3,
0xE4,
0xE5,
0xE6,
0xE7,
0xE8,
0xE9,
0xEA,
0xF1,
0xF2,
0xF3,
0xF4,
0xF5,
0xF6,
0xF7,
0xF8,
0xF9,
0xFA,
0xFF,
0xDA,
0x00,
0x08,
0x01,
0x01,
0x00,
0x00,
0x3F,
0x00,
0xFB,
0xD5,
0xDB,
0x20,
0xA8,
0xBA,
0xAE,
0xAF,
0xDA,
0xAD,
0x28,
0xA6,
0x02,
0x8A,
0x28,
0x03,
0xFF,
0xD9,
]
)
temp_dir = tempfile.mkdtemp(prefix="thrillwiki_test_")
temp_path = Path(temp_dir)
@@ -146,6 +464,7 @@ def test_images():
# Cleanup
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -222,10 +541,7 @@ def submission_pending(db):
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, _ = User.objects.get_or_create(username="fsm_test_submitter", defaults={"email": "fsm_test@example.com"})
user.set_password("testpass123")
user.save()
@@ -243,7 +559,7 @@ def submission_pending(db):
submission_type="EDIT",
changes={"description": "FSM test submission"},
reason="FSM e2e test",
status="PENDING"
status="PENDING",
)
yield submission
@@ -265,8 +581,7 @@ def submission_approved(db):
User = get_user_model()
user, _ = User.objects.get_or_create(
username="fsm_test_submitter_approved",
defaults={"email": "fsm_approved@example.com"}
username="fsm_test_submitter_approved", defaults={"email": "fsm_approved@example.com"}
)
park = Park.objects.first()
@@ -282,7 +597,7 @@ def submission_approved(db):
submission_type="EDIT",
changes={"description": "Already approved"},
reason="FSM approved test",
status="APPROVED"
status="APPROVED",
)
yield submission
@@ -296,11 +611,7 @@ 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"
)
park = ParkFactory(name="FSM Test Park Operating", slug="fsm-test-park-operating", status="OPERATING")
yield park
@@ -310,11 +621,7 @@ 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"
)
park = ParkFactory(name="FSM Test Park Closed Temp", slug="fsm-test-park-closed-temp", status="CLOSED_TEMP")
yield park
@@ -330,7 +637,7 @@ def park_closed_perm(db):
name="FSM Test Park Closed Perm",
slug="fsm-test-park-closed-perm",
status="CLOSED_PERM",
closing_date=date.today() - timedelta(days=365)
closing_date=date.today() - timedelta(days=365),
)
yield park
@@ -342,10 +649,7 @@ def ride_operating(db, park_operating):
from tests.factories import RideFactory
ride = RideFactory(
name="FSM Test Ride Operating",
slug="fsm-test-ride-operating",
park=park_operating,
status="OPERATING"
name="FSM Test Ride Operating", slug="fsm-test-ride-operating", park=park_operating, status="OPERATING"
)
yield ride
@@ -356,12 +660,7 @@ 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"
)
ride = RideFactory(name="FSM Test Ride SBNO", slug="fsm-test-ride-sbno", park=park_operating, status="SBNO")
yield ride
@@ -378,7 +677,7 @@ def ride_closed_perm(db, park_operating):
slug="fsm-test-ride-closed-perm",
park=park_operating,
status="CLOSED_PERM",
closing_date=date.today() - timedelta(days=365)
closing_date=date.today() - timedelta(days=365),
)
yield ride
@@ -393,10 +692,7 @@ def queue_item_pending(db):
User = get_user_model()
user, _ = User.objects.get_or_create(
username="fsm_queue_flagger",
defaults={"email": "fsm_queue@example.com"}
)
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",
@@ -404,7 +700,7 @@ def queue_item_pending(db):
priority="MEDIUM",
title="FSM Test Queue Item",
description="Queue item for FSM e2e testing",
flagged_by=user
flagged_by=user,
)
yield queue_item
@@ -423,8 +719,7 @@ def bulk_operation_pending(db):
User = get_user_model()
user, _ = User.objects.get_or_create(
username="fsm_bulk_creator",
defaults={"email": "fsm_bulk@example.com", "is_staff": True}
username="fsm_bulk_creator", defaults={"email": "fsm_bulk@example.com", "is_staff": True}
)
operation = BulkOperation.objects.create(
@@ -434,7 +729,7 @@ def bulk_operation_pending(db):
description="FSM Test Bulk Operation",
parameters={"test": True},
created_by=user,
total_items=10
total_items=10,
)
yield operation
@@ -455,6 +750,7 @@ def live_server(live_server_url):
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
@@ -469,11 +765,7 @@ def moderator_user(db):
User = get_user_model()
user, _ = User.objects.get_or_create(
username="moderator",
defaults={
"email": "moderator@example.com",
"is_staff": True
}
username="moderator", defaults={"email": "moderator@example.com", "is_staff": True}
)
user.set_password("modpass123")
user.save()
@@ -488,10 +780,7 @@ def regular_user(db):
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testuser",
defaults={"email": "testuser@example.com"}
)
user, _ = User.objects.get_or_create(username="testuser", defaults={"email": "testuser@example.com"})
user.set_password("testpass123")
user.save()
@@ -503,14 +792,7 @@ def parks_data(db):
"""Create test parks for E2E testing."""
from tests.factories import ParkFactory
parks = [
ParkFactory(
name=f"E2E Test Park {i}",
slug=f"e2e-test-park-{i}",
status="OPERATING"
)
for i in range(3)
]
parks = [ParkFactory(name=f"E2E Test Park {i}", slug=f"e2e-test-park-{i}", status="OPERATING") for i in range(3)]
return parks
@@ -527,7 +809,7 @@ def rides_data(db, parks_data):
name=f"E2E Test Ride {park.name} {i}",
slug=f"e2e-test-ride-{park.slug}-{i}",
park=park,
status="OPERATING"
status="OPERATING",
)
rides.append(ride)

View File

@@ -26,9 +26,7 @@ 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
):
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
@@ -42,7 +40,8 @@ class TestInvalidTransitionErrors:
# Attempt an invalid transition via direct API call
# For example, trying to reopen an already operating park
response = mod_page.evaluate(f"""
response = mod_page.evaluate(
f"""
async () => {{
const response = await fetch('/core/fsm/parks/park/{park.pk}/transition/transition_to_operating/', {{
method: 'POST',
@@ -58,21 +57,20 @@ class TestInvalidTransitionErrors:
hxTrigger: response.headers.get('HX-Trigger')
}};
}}
""")
"""
)
# Should return error status (400)
if response:
assert response.get('status') in [400, 403]
assert response.get("status") in [400, 403]
# Check for error toast in HX-Trigger header
hx_trigger = response.get('hxTrigger')
hx_trigger = response.get("hxTrigger")
if hx_trigger:
assert 'showToast' in hx_trigger
assert 'error' in hx_trigger.lower()
assert "showToast" in hx_trigger
assert "error" in hx_trigger.lower()
def test_already_transitioned_shows_error(
self, mod_page: Page, live_server, db
):
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 django.contrib.contenttypes.models import ContentType
@@ -84,8 +82,7 @@ class TestInvalidTransitionErrors:
# Create an already-approved submission
user, _ = User.objects.get_or_create(
username="testsubmitter2",
defaults={"email": "testsubmitter2@example.com"}
username="testsubmitter2", defaults={"email": "testsubmitter2@example.com"}
)
park = Park.objects.first()
@@ -101,7 +98,7 @@ class TestInvalidTransitionErrors:
submission_type="EDIT",
changes={"description": "Already approved"},
reason="Already approved test",
status="APPROVED" # Already approved
status="APPROVED", # Already approved
)
try:
@@ -109,7 +106,8 @@ class TestInvalidTransitionErrors:
mod_page.wait_for_load_state("networkidle")
# Try to approve again via direct API call
response = mod_page.evaluate(f"""
response = mod_page.evaluate(
f"""
async () => {{
const response = await fetch('/core/fsm/moderation/editsubmission/{submission.pk}/transition/transition_to_approved/', {{
method: 'POST',
@@ -125,18 +123,17 @@ class TestInvalidTransitionErrors:
hxTrigger: response.headers.get('HX-Trigger')
}};
}}
""")
"""
)
# Should return error status
if response:
assert response.get('status') in [400, 403]
assert response.get("status") in [400, 403]
finally:
submission.delete()
def test_nonexistent_transition_shows_error(
self, mod_page: Page, live_server, db
):
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
@@ -148,7 +145,8 @@ class TestInvalidTransitionErrors:
mod_page.wait_for_load_state("networkidle")
# Try to call a non-existent transition
response = mod_page.evaluate(f"""
response = mod_page.evaluate(
f"""
async () => {{
const response = await fetch('/core/fsm/parks/park/{park.pk}/transition/nonexistent_transition/', {{
method: 'POST',
@@ -164,19 +162,18 @@ class TestInvalidTransitionErrors:
hxTrigger: response.headers.get('HX-Trigger')
}};
}}
""")
"""
)
# Should return error status (400 or 404)
if response:
assert response.get('status') in [400, 404]
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
):
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
@@ -187,19 +184,14 @@ class TestLoadingIndicators:
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"
)
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_()
))
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())
@@ -212,19 +204,17 @@ class TestLoadingIndicators:
# The loading indicator should appear (may be brief)
# We wait a short time for it to appear
try:
try: # noqa: SIM105
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]')
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
):
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
@@ -235,19 +225,14 @@ class TestLoadingIndicators:
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"
)
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.route("**/core/fsm/**", lambda route: (mod_page.wait_for_timeout(1000), route.continue_()))
mod_page.on("dialog", lambda dialog: dialog.accept())
@@ -261,9 +246,7 @@ class TestLoadingIndicators:
class TestNetworkErrorHandling:
"""Tests for handling network errors during transitions."""
def test_network_error_shows_error_toast(
self, mod_page: Page, live_server, db
):
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
@@ -277,10 +260,8 @@ class TestNetworkErrorHandling:
# 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"
)
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")
@@ -295,7 +276,7 @@ class TestNetworkErrorHandling:
error_indicator = mod_page.locator('[data-toast].error, .htmx-error, [class*="error"]')
# May show as toast or inline error
try:
try: # noqa: SIM105
expect(error_indicator.first).to_be_visible(timeout=5000)
except Exception:
# Error may be handled differently
@@ -305,9 +286,7 @@ class TestNetworkErrorHandling:
park.refresh_from_db()
assert park.status == "OPERATING"
def test_server_error_shows_user_friendly_message(
self, mod_page: Page, live_server, db
):
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
@@ -319,17 +298,18 @@ class TestNetworkErrorHandling:
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"
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")
@@ -338,7 +318,7 @@ class TestNetworkErrorHandling:
close_temp_btn.click()
# Should show user-friendly error message
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Should not show technical error details to user
@@ -349,9 +329,7 @@ class TestNetworkErrorHandling:
class TestConfirmationDialogs:
"""Tests for confirmation dialogs on dangerous transitions."""
def test_confirm_dialog_appears_for_reject_transition(
self, mod_page: Page, live_server, db
):
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 django.contrib.contenttypes.models import ContentType
@@ -362,8 +340,7 @@ class TestConfirmationDialogs:
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testsubmitter3",
defaults={"email": "testsubmitter3@example.com"}
username="testsubmitter3", defaults={"email": "testsubmitter3@example.com"}
)
park = Park.objects.first()
@@ -379,7 +356,7 @@ class TestConfirmationDialogs:
submission_type="EDIT",
changes={"description": "Confirm dialog test"},
reason="Confirm dialog test",
status="PENDING"
status="PENDING",
)
dialog_shown = {"shown": False}
@@ -395,9 +372,7 @@ class TestConfirmationDialogs:
mod_page.on("dialog", handle_dialog)
submission_row = mod_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
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")
@@ -413,9 +388,7 @@ class TestConfirmationDialogs:
finally:
submission.delete()
def test_cancel_confirm_dialog_prevents_transition(
self, mod_page: Page, live_server, db
):
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 django.contrib.contenttypes.models import ContentType
@@ -426,8 +399,7 @@ class TestConfirmationDialogs:
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testsubmitter4",
defaults={"email": "testsubmitter4@example.com"}
username="testsubmitter4", defaults={"email": "testsubmitter4@example.com"}
)
park = Park.objects.first()
@@ -443,7 +415,7 @@ class TestConfirmationDialogs:
submission_type="EDIT",
changes={"description": "Cancel confirm test"},
reason="Cancel confirm test",
status="PENDING"
status="PENDING",
)
try:
@@ -453,9 +425,7 @@ class TestConfirmationDialogs:
# Dismiss (cancel) the dialog
mod_page.on("dialog", lambda dialog: dialog.dismiss())
submission_row = mod_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
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")
@@ -472,9 +442,7 @@ class TestConfirmationDialogs:
finally:
submission.delete()
def test_accept_confirm_dialog_executes_transition(
self, mod_page: Page, live_server, db
):
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 django.contrib.contenttypes.models import ContentType
@@ -485,8 +453,7 @@ class TestConfirmationDialogs:
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testsubmitter5",
defaults={"email": "testsubmitter5@example.com"}
username="testsubmitter5", defaults={"email": "testsubmitter5@example.com"}
)
park = Park.objects.first()
@@ -502,7 +469,7 @@ class TestConfirmationDialogs:
submission_type="EDIT",
changes={"description": "Accept confirm test"},
reason="Accept confirm test",
status="PENDING"
status="PENDING",
)
try:
@@ -512,9 +479,7 @@ class TestConfirmationDialogs:
# Accept the dialog
mod_page.on("dialog", lambda dialog: dialog.accept())
submission_row = mod_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
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")
@@ -522,7 +487,7 @@ class TestConfirmationDialogs:
reject_btn.click()
# Wait for transition to complete
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Verify submission status WAS changed
@@ -536,9 +501,7 @@ class TestConfirmationDialogs:
class TestValidationErrors:
"""Tests for validation error handling."""
def test_validation_error_shows_specific_message(
self, mod_page: Page, live_server, db
):
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
@@ -563,9 +526,7 @@ class TestValidationErrors:
class TestToastNotificationBehavior:
"""Tests for toast notification appearance and behavior."""
def test_success_toast_auto_dismisses(
self, mod_page: Page, live_server, db
):
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
@@ -576,10 +537,8 @@ class TestToastNotificationBehavior:
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"
)
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")
@@ -589,16 +548,14 @@ class TestToastNotificationBehavior:
close_temp_btn.click()
# Toast should appear
toast = mod_page.locator('[data-toast]')
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
):
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
@@ -610,19 +567,18 @@ class TestToastNotificationBehavior:
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"
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")
@@ -631,15 +587,13 @@ class TestToastNotificationBehavior:
close_temp_btn.click()
# Error toast should appear with error styling
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Should have error/danger styling (red)
expect(toast).to_have_class(re.compile(r"error|danger|bg-red|text-red"))
def test_success_toast_has_correct_styling(
self, mod_page: Page, live_server, db
):
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
@@ -654,10 +608,8 @@ class TestToastNotificationBehavior:
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"
)
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")
@@ -667,7 +619,7 @@ class TestToastNotificationBehavior:
close_temp_btn.click()
# Success toast should appear with success styling
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Should have success styling (green)

View File

@@ -22,9 +22,7 @@ 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
):
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/")
@@ -34,9 +32,7 @@ class TestUnauthenticatedUserPermissions:
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
):
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
@@ -48,18 +44,14 @@ class TestUnauthenticatedUserPermissions:
page.wait_for_load_state("networkidle")
# Status action buttons should NOT be visible
status_actions = page.locator('[data-park-status-actions]')
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"
)
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
):
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
@@ -70,7 +62,7 @@ class TestUnauthenticatedUserPermissions:
# 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"}
headers={"HX-Request": "true"},
)
# Should get 403 Forbidden
@@ -84,9 +76,7 @@ class TestUnauthenticatedUserPermissions:
class TestRegularUserPermissions:
"""Tests for regular (non-moderator) user permission guards."""
def test_regular_user_cannot_approve_submission(
self, auth_page: Page, live_server, db
):
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 django.contrib.contenttypes.models import ContentType
@@ -114,7 +104,7 @@ class TestRegularUserPermissions:
submission_type="EDIT",
changes={"description": "Test change"},
reason="Permission test",
status="PENDING"
status="PENDING",
)
try:
@@ -126,9 +116,7 @@ class TestRegularUserPermissions:
# If somehow on dashboard, verify no approve button
if "dashboard" in current_url:
submission_row = auth_page.locator(
f'[data-submission-id="{submission.pk}"]'
)
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()
@@ -136,7 +124,7 @@ class TestRegularUserPermissions:
# 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"}
headers={"HX-Request": "true"},
)
# Should be denied (403 or 302 redirect)
@@ -149,9 +137,7 @@ class TestRegularUserPermissions:
finally:
submission.delete()
def test_regular_user_cannot_change_park_status(
self, auth_page: Page, live_server, db
):
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
@@ -163,18 +149,16 @@ class TestRegularUserPermissions:
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]')
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"
)
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"}
headers={"HX-Request": "true"},
)
# Should be denied
@@ -184,9 +168,7 @@ class TestRegularUserPermissions:
park.refresh_from_db()
assert park.status == "OPERATING"
def test_regular_user_cannot_change_ride_status(
self, auth_page: Page, live_server, db
):
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
@@ -194,24 +176,20 @@ class TestRegularUserPermissions:
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.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]')
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"
)
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"}
headers={"HX-Request": "true"},
)
# Should be denied
@@ -225,9 +203,7 @@ class TestRegularUserPermissions:
class TestModeratorPermissions:
"""Tests for moderator-specific permission guards."""
def test_moderator_can_approve_submission(
self, mod_page: Page, live_server, db
):
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 django.contrib.contenttypes.models import ContentType
@@ -240,11 +216,7 @@ class TestModeratorPermissions:
# 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"
)
user = User.objects.create_user(username="testuser", email="testuser@example.com", password="testpass123")
park = Park.objects.first()
if not park:
@@ -259,7 +231,7 @@ class TestModeratorPermissions:
submission_type="EDIT",
changes={"description": "Test change for moderator"},
reason="Moderator permission test",
status="PENDING"
status="PENDING",
)
try:
@@ -267,9 +239,7 @@ class TestModeratorPermissions:
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}"]'
)
submission_row = mod_page.locator(f'[data-submission-id="{submission.pk}"]')
if submission_row.is_visible():
# Should see approve button
@@ -279,9 +249,7 @@ class TestModeratorPermissions:
finally:
submission.delete()
def test_moderator_can_change_park_status(
self, mod_page: Page, live_server, db
):
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
@@ -293,18 +261,14 @@ class TestModeratorPermissions:
mod_page.wait_for_load_state("networkidle")
# Status action buttons SHOULD be visible to moderator
status_actions = mod_page.locator('[data-park-status-actions]')
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"
)
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
):
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
@@ -327,22 +291,18 @@ class TestModeratorPermissions:
# 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]')
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
)
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
):
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
@@ -355,9 +315,12 @@ class TestPermissionDeniedErrorHandling:
auth_page.wait_for_load_state("networkidle")
# Make the request programmatically with HTMX header
response = auth_page.evaluate("""
response = auth_page.evaluate(
"""
async () => {
const response = await fetch('/core/fsm/parks/park/""" + str(park.pk) + """/transition/transition_to_closed_temp/', {
const response = await fetch('/core/fsm/parks/park/"""
+ str(park.pk)
+ """/transition/transition_to_closed_temp/', {
method: 'POST',
headers: {
'HX-Request': 'true',
@@ -370,18 +333,17 @@ class TestPermissionDeniedErrorHandling:
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 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()
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
):
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
@@ -395,9 +357,12 @@ class TestPermissionDeniedErrorHandling:
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
auth_page.wait_for_load_state("networkidle")
auth_page.evaluate("""
auth_page.evaluate(
"""
async () => {
await fetch('/core/fsm/parks/park/""" + str(park.pk) + """/transition/transition_to_closed_temp/', {
await fetch('/core/fsm/parks/park/"""
+ str(park.pk)
+ """/transition/transition_to_closed_temp/', {
method: 'POST',
headers: {
'HX-Request': 'true',
@@ -406,7 +371,8 @@ class TestPermissionDeniedErrorHandling:
credentials: 'include'
});
}
""")
"""
)
# Verify database state did NOT change
park.refresh_from_db()
@@ -416,9 +382,7 @@ class TestPermissionDeniedErrorHandling:
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
):
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
@@ -430,7 +394,7 @@ class TestTransitionButtonVisibility:
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]')
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)
@@ -442,9 +406,7 @@ class TestTransitionButtonVisibility:
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
):
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
@@ -461,21 +423,17 @@ class TestTransitionButtonVisibility:
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]')
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"
)
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
):
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
@@ -492,7 +450,7 @@ class TestTransitionButtonVisibility:
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]')
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")

View File

@@ -30,10 +30,7 @@ def pending_submission(db):
User = get_user_model()
# Get or create test user
user, _ = User.objects.get_or_create(
username="testsubmitter",
defaults={"email": "testsubmitter@example.com"}
)
user, _ = User.objects.get_or_create(username="testsubmitter", defaults={"email": "testsubmitter@example.com"})
user.set_password("testpass123")
user.save()
@@ -51,7 +48,7 @@ def pending_submission(db):
submission_type="EDIT",
changes={"description": "Updated park description for testing"},
reason="E2E test submission",
status="PENDING"
status="PENDING",
)
yield submission
@@ -73,8 +70,7 @@ def pending_photo_submission(db):
# Get or create test user
user, _ = User.objects.get_or_create(
username="testphotosubmitter",
defaults={"email": "testphotosubmitter@example.com"}
username="testphotosubmitter", defaults={"email": "testphotosubmitter@example.com"}
)
user.set_password("testpass123")
user.save()
@@ -89,6 +85,7 @@ def pending_photo_submission(db):
# 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")
@@ -96,12 +93,7 @@ def pending_photo_submission(db):
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"
user=user, content_type=content_type, object_id=park.pk, photo=photo, caption="E2E test photo", status="PENDING"
)
yield submission
@@ -113,9 +105,7 @@ def pending_photo_submission(db):
class TestEditSubmissionTransitions:
"""Tests for EditSubmission FSM transitions via HTMX."""
def test_submission_approve_transition_as_moderator(
self, mod_page: Page, pending_submission, live_server
):
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/")
@@ -127,7 +117,7 @@ class TestEditSubmissionTransitions:
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]')
status_badge = submission_row.locator("[data-status-badge]")
expect(status_badge).to_contain_text("Pending")
# Click the approve button
@@ -140,7 +130,7 @@ class TestEditSubmissionTransitions:
approve_btn.click()
# Wait for toast notification
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
expect(toast).to_contain_text("approved")
@@ -151,9 +141,7 @@ class TestEditSubmissionTransitions:
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
):
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")
@@ -161,7 +149,7 @@ class TestEditSubmissionTransitions:
submission_row = mod_page.locator(f'[data-submission-id="{pending_submission.pk}"]')
# Verify initial status
status_badge = submission_row.locator('[data-status-badge]')
status_badge = submission_row.locator("[data-status-badge]")
expect(status_badge).to_contain_text("Pending")
# Click reject button
@@ -173,7 +161,7 @@ class TestEditSubmissionTransitions:
reject_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
expect(toast).to_contain_text("rejected")
@@ -184,9 +172,7 @@ class TestEditSubmissionTransitions:
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
):
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")
@@ -194,7 +180,7 @@ class TestEditSubmissionTransitions:
submission_row = mod_page.locator(f'[data-submission-id="{pending_submission.pk}"]')
# Verify initial status
status_badge = submission_row.locator('[data-status-badge]')
status_badge = submission_row.locator("[data-status-badge]")
expect(status_badge).to_contain_text("Pending")
# Click escalate button
@@ -206,7 +192,7 @@ class TestEditSubmissionTransitions:
escalate_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
expect(toast).to_contain_text("escalated")
@@ -221,9 +207,7 @@ class TestEditSubmissionTransitions:
class TestPhotoSubmissionTransitions:
"""Tests for PhotoSubmission FSM transitions via HTMX."""
def test_photo_submission_approve_transition(
self, mod_page: Page, pending_photo_submission, live_server
):
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")
@@ -234,9 +218,7 @@ class TestPhotoSubmissionTransitions:
photos_tab.click()
# Find the photo submission row
submission_row = mod_page.locator(
f'[data-photo-submission-id="{pending_photo_submission.pk}"]'
)
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")
@@ -248,7 +230,7 @@ class TestPhotoSubmissionTransitions:
approve_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
expect(toast).to_contain_text("approved")
@@ -256,9 +238,7 @@ class TestPhotoSubmissionTransitions:
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
):
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")
@@ -269,9 +249,7 @@ class TestPhotoSubmissionTransitions:
photos_tab.click()
# Find the photo submission row
submission_row = mod_page.locator(
f'[data-photo-submission-id="{pending_photo_submission.pk}"]'
)
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")
@@ -283,7 +261,7 @@ class TestPhotoSubmissionTransitions:
reject_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
expect(toast).to_contain_text("rejected")
@@ -304,10 +282,7 @@ class TestModerationQueueTransitions:
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testflagger",
defaults={"email": "testflagger@example.com"}
)
user, _ = User.objects.get_or_create(username="testflagger", defaults={"email": "testflagger@example.com"})
queue_item = ModerationQueue.objects.create(
item_type="CONTENT_REVIEW",
@@ -315,16 +290,14 @@ class TestModerationQueueTransitions:
priority="MEDIUM",
title="E2E Test Queue Item",
description="Queue item for E2E testing",
flagged_by=user
flagged_by=user,
)
yield queue_item
queue_item.delete()
def test_moderation_queue_start_transition(
self, mod_page: Page, pending_queue_item, live_server
):
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")
@@ -340,16 +313,14 @@ class TestModerationQueueTransitions:
start_btn.click()
# Verify status updated to IN_PROGRESS
status_badge = queue_row.locator('[data-status-badge]')
status_badge = queue_row.locator("[data-status-badge]")
expect(status_badge).to_contain_text("In Progress", timeout=5000)
# Verify database state
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
):
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"
@@ -370,7 +341,7 @@ class TestModerationQueueTransitions:
complete_btn.click()
# Verify toast and status
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
pending_queue_item.refresh_from_db()
@@ -390,8 +361,7 @@ class TestBulkOperationTransitions:
User = get_user_model()
user, _ = User.objects.get_or_create(
username="testadmin",
defaults={"email": "testadmin@example.com", "is_staff": True}
username="testadmin", defaults={"email": "testadmin@example.com", "is_staff": True}
)
operation = BulkOperation.objects.create(
@@ -401,24 +371,20 @@ class TestBulkOperationTransitions:
description="E2E Test Bulk Operation",
parameters={"test": True},
created_by=user,
total_items=10
total_items=10,
)
yield operation
operation.delete()
def test_bulk_operation_cancel_transition(
self, mod_page: Page, pending_bulk_operation, live_server
):
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}"]'
)
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")
@@ -430,7 +396,7 @@ class TestBulkOperationTransitions:
cancel_btn.click()
# Verify toast
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
expect(toast).to_contain_text("cancel")
@@ -442,16 +408,12 @@ class TestBulkOperationTransitions:
class TestTransitionLoadingStates:
"""Tests for loading indicators during FSM transitions."""
def test_loading_indicator_appears_during_transition(
self, mod_page: Page, pending_submission, live_server
):
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}"]'
)
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")
@@ -466,8 +428,8 @@ class TestTransitionLoadingStates:
# Check for htmx-indicator visibility (may be brief)
# The indicator should become visible during the request
submission_row.locator('.htmx-indicator')
submission_row.locator(".htmx-indicator")
# Wait for transition to complete
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)

View File

@@ -32,9 +32,7 @@ class TestParkListPage:
first_park = parks_data[0]
expect(page.get_by_text(first_park.name)).to_be_visible()
def test__park_list__click_park__navigates_to_detail(
self, page: Page, live_server, parks_data
):
def test__park_list__click_park__navigates_to_detail(self, page: Page, live_server, parks_data):
"""Test clicking a park navigates to detail page."""
page.goto(f"{live_server.url}/parks/")
@@ -51,9 +49,7 @@ class TestParkListPage:
page.goto(f"{live_server.url}/parks/")
# Find search input
search_input = page.locator(
"input[type='search'], input[name='q'], input[placeholder*='search' i]"
)
search_input = page.locator("input[type='search'], input[name='q'], input[placeholder*='search' i]")
if search_input.count() > 0:
search_input.first.fill("E2E Test Park 0")
@@ -83,9 +79,7 @@ class TestParkDetailPage:
page.goto(f"{live_server.url}/parks/{park.slug}/")
# Look for rides section/tab
page.locator(
"[data-testid='rides-section'], #rides, [role='tabpanel']"
)
page.locator("[data-testid='rides-section'], #rides, [role='tabpanel']")
# Or a rides tab
rides_tab = page.get_by_role("tab", name="Rides")
@@ -103,9 +97,7 @@ class TestParkDetailPage:
page.goto(f"{live_server.url}/parks/{park.slug}/")
# Status badge or indicator should be visible
status_indicator = page.locator(
".status-badge, [data-testid='status'], .park-status"
)
status_indicator = page.locator(".status-badge, [data-testid='status'], .park-status")
expect(status_indicator.first).to_be_visible()
@@ -118,9 +110,7 @@ class TestParkFiltering:
page.goto(f"{live_server.url}/parks/")
# Find status filter
status_filter = page.locator(
"select[name='status'], [data-testid='status-filter']"
)
status_filter = page.locator("select[name='status'], [data-testid='status-filter']")
if status_filter.count() > 0:
status_filter.first.select_option("OPERATING")
@@ -135,9 +125,7 @@ class TestParkFiltering:
page.goto(f"{live_server.url}/parks/")
# Find clear filters button
clear_btn = page.locator(
"[data-testid='clear-filters'], button:has-text('Clear')"
)
clear_btn = page.locator("[data-testid='clear-filters'], button:has-text('Clear')")
if clear_btn.count() > 0:
clear_btn.first.click()
@@ -164,9 +152,7 @@ class TestParkNavigation:
expect(page).to_have_url("**/parks/**")
def test__back_button__returns_to_previous_page(
self, page: Page, live_server, parks_data
):
def test__back_button__returns_to_previous_page(self, page: Page, live_server, parks_data):
"""Test browser back button returns to previous page."""
page.goto(f"{live_server.url}/parks/")

View File

@@ -26,11 +26,7 @@ def operating_park(db):
from tests.factories import ParkFactory
# Use factory to create a complete park
park = ParkFactory(
name="E2E Test Park",
slug="e2e-test-park",
status="OPERATING"
)
park = ParkFactory(name="E2E Test Park", slug="e2e-test-park", status="OPERATING")
yield park
@@ -42,12 +38,7 @@ 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"
)
ride = RideFactory(name="E2E Test Ride", slug="e2e-test-ride", park=operating_park, status="OPERATING")
yield ride
@@ -55,31 +46,25 @@ def operating_ride(db, operating_park):
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
):
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]')
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"
)
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]')
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"
)
close_temp_btn = mod_page.get_by_role("button", name="Close Temporarily")
# Handle confirmation dialog
mod_page.on("dialog", lambda dialog: dialog.accept())
@@ -87,7 +72,7 @@ class TestParkStatusTransitions:
close_temp_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
@@ -97,9 +82,7 @@ class TestParkStatusTransitions:
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
):
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"
@@ -109,18 +92,18 @@ class TestParkStatusTransitions:
mod_page.wait_for_load_state("networkidle")
# Verify initial status badge shows Temporarily Closed
status_badge = mod_page.locator('[data-status-badge]')
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]')
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]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
@@ -130,34 +113,28 @@ class TestParkStatusTransitions:
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
):
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]')
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"
)
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]')
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"
)
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]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
@@ -167,9 +144,7 @@ class TestParkStatusTransitions:
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
):
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"
@@ -179,25 +154,23 @@ class TestParkStatusTransitions:
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]')
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]')
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"
)
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]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
@@ -207,24 +180,18 @@ class TestParkStatusTransitions:
operating_park.refresh_from_db()
assert operating_park.status == "DEMOLISHED"
def test_park_available_transitions_update(
self, mod_page: Page, operating_park, live_server
):
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]')
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()
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()
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"
@@ -234,37 +201,29 @@ class TestParkStatusTransitions:
mod_page.wait_for_load_state("networkidle")
# Now should have Reopen button
expect(
status_section.get_by_role("button", name="Reopen")
).to_be_visible()
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
):
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.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]')
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"
)
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]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
@@ -274,23 +233,19 @@ class TestRideStatusTransitions:
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
):
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.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]')
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]')
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")
@@ -299,7 +254,7 @@ class TestRideStatusTransitions:
sbno_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
@@ -309,21 +264,17 @@ class TestRideStatusTransitions:
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
):
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.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]')
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")
@@ -332,7 +283,7 @@ class TestRideStatusTransitions:
reopen_btn.click()
# Verify toast notification
toast = mod_page.locator('[data-toast]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
@@ -342,36 +293,28 @@ class TestRideStatusTransitions:
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
):
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.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]')
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"
)
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]')
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"
)
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]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
@@ -381,41 +324,33 @@ class TestRideStatusTransitions:
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
):
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.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]')
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"
)
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]')
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"
)
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]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
@@ -425,41 +360,33 @@ class TestRideStatusTransitions:
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
):
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.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]')
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"
)
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]')
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"
)
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]')
toast = mod_page.locator("[data-toast]")
expect(toast).to_be_visible(timeout=5000)
# Verify status badge updated
@@ -473,28 +400,22 @@ class TestRideStatusTransitions:
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
):
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.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_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"
)
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]')
status_badge = mod_page.locator("[data-status-badge]")
expect(status_badge).to_contain_text("Closing", timeout=5000)
# Verify database state
@@ -503,9 +424,7 @@ class TestRideClosingWorkflow:
else:
pytest.skip("Set Closing button not available")
def test_ride_closing_shows_countdown(
self, mod_page: Page, operating_ride, live_server
):
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)
@@ -513,37 +432,31 @@ class TestRideClosingWorkflow:
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.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]')
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]')
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
):
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]')
status_badge = mod_page.locator("[data-status-badge]")
expect(status_badge).to_have_class(re.compile(r"bg-green|text-green|success"))
def test_closed_temp_status_badge_style(
self, mod_page: Page, operating_park, live_server
):
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()
@@ -551,12 +464,10 @@ class TestStatusBadgeStyling:
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]')
status_badge = mod_page.locator("[data-status-badge]")
expect(status_badge).to_have_class(re.compile(r"bg-yellow|text-yellow|warning"))
def test_closed_perm_status_badge_style(
self, mod_page: Page, operating_park, live_server
):
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()
@@ -564,12 +475,10 @@ class TestStatusBadgeStyling:
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]')
status_badge = mod_page.locator("[data-status-badge]")
expect(status_badge).to_have_class(re.compile(r"bg-red|text-red|danger"))
def test_demolished_status_badge_style(
self, mod_page: Page, operating_park, live_server
):
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()
@@ -577,5 +486,5 @@ class TestStatusBadgeStyling:
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]')
status_badge = mod_page.locator("[data-status-badge]")
expect(status_badge).to_have_class(re.compile(r"bg-gray|text-gray|muted"))

View File

@@ -24,9 +24,7 @@ class TestReviewSubmission:
reviews_tab.click()
# Click write review button
write_review = auth_page.locator(
"button:has-text('Write Review'), a:has-text('Write Review')"
)
write_review = auth_page.locator("button:has-text('Write Review'), a:has-text('Write Review')")
if write_review.count() > 0:
write_review.first.click()
@@ -36,9 +34,7 @@ class TestReviewSubmission:
expect(auth_page.locator("input[name='title'], textarea[name='title']").first).to_be_visible()
expect(auth_page.locator("textarea[name='content'], textarea[name='review']").first).to_be_visible()
def test__review_submission__valid_data__creates_review(
self, auth_page: Page, live_server, parks_data
):
def test__review_submission__valid_data__creates_review(self, auth_page: Page, live_server, parks_data):
"""Test submitting a valid review creates it."""
park = parks_data[0]
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
@@ -48,9 +44,7 @@ class TestReviewSubmission:
if reviews_tab.count() > 0:
reviews_tab.click()
write_review = auth_page.locator(
"button:has-text('Write Review'), a:has-text('Write Review')"
)
write_review = auth_page.locator("button:has-text('Write Review'), a:has-text('Write Review')")
if write_review.count() > 0:
write_review.first.click()
@@ -63,9 +57,7 @@ class TestReviewSubmission:
# May be radio buttons or stars
auth_page.locator("input[name='rating'][value='5']").click()
auth_page.locator("input[name='title'], textarea[name='title']").first.fill(
"E2E Test Review Title"
)
auth_page.locator("input[name='title'], textarea[name='title']").first.fill("E2E Test Review Title")
auth_page.locator("textarea[name='content'], textarea[name='review']").first.fill(
"This is an E2E test review content."
)
@@ -75,9 +67,7 @@ class TestReviewSubmission:
# Should show success or redirect
auth_page.wait_for_timeout(500)
def test__review_submission__missing_rating__shows_error(
self, auth_page: Page, live_server, parks_data
):
def test__review_submission__missing_rating__shows_error(self, auth_page: Page, live_server, parks_data):
"""Test submitting review without rating shows error."""
park = parks_data[0]
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
@@ -86,20 +76,14 @@ class TestReviewSubmission:
if reviews_tab.count() > 0:
reviews_tab.click()
write_review = auth_page.locator(
"button:has-text('Write Review'), a:has-text('Write Review')"
)
write_review = auth_page.locator("button:has-text('Write Review'), a:has-text('Write Review')")
if write_review.count() > 0:
write_review.first.click()
# Fill only title and content, skip rating
auth_page.locator("input[name='title'], textarea[name='title']").first.fill(
"Missing Rating Review"
)
auth_page.locator("textarea[name='content'], textarea[name='review']").first.fill(
"Review without rating"
)
auth_page.locator("input[name='title'], textarea[name='title']").first.fill("Missing Rating Review")
auth_page.locator("textarea[name='content'], textarea[name='review']").first.fill("Review without rating")
auth_page.get_by_role("button", name="Submit").click()
@@ -123,9 +107,7 @@ class TestReviewDisplay:
reviews_tab.click()
# Reviews should be displayed
reviews_section = page.locator(
"[data-testid='reviews-list'], .reviews-list, .review-item"
)
reviews_section = page.locator("[data-testid='reviews-list'], .reviews-list, .review-item")
if reviews_section.count() > 0:
expect(reviews_section.first).to_be_visible()
@@ -136,9 +118,7 @@ class TestReviewDisplay:
page.goto(f"{page.url}") # Stay on current page after fixture
# Rating should be visible (stars, number, etc.)
rating = page.locator(
".rating, .stars, [data-testid='rating']"
)
rating = page.locator(".rating, .stars, [data-testid='rating']")
if rating.count() > 0:
expect(rating.first).to_be_visible()
@@ -153,9 +133,7 @@ class TestReviewDisplay:
reviews_tab.click()
# Author name should be visible in review
author = page.locator(
".review-author, .author, [data-testid='author']"
)
author = page.locator(".review-author, .author, [data-testid='author']")
if author.count() > 0:
expect(author.first).to_be_visible()
@@ -170,9 +148,7 @@ class TestReviewEditing:
# Navigate to reviews after creating one
# Look for edit button on own review
edit_button = auth_page.locator(
"button:has-text('Edit'), a:has-text('Edit Review')"
)
edit_button = auth_page.locator("button:has-text('Edit'), a:has-text('Edit Review')")
if edit_button.count() > 0:
expect(edit_button.first).to_be_visible()
@@ -180,17 +156,13 @@ class TestReviewEditing:
def test__edit_review__updates_content(self, auth_page: Page, live_server, test_review):
"""Test editing review updates the content."""
# Find and click edit
edit_button = auth_page.locator(
"button:has-text('Edit'), a:has-text('Edit Review')"
)
edit_button = auth_page.locator("button:has-text('Edit'), a:has-text('Edit Review')")
if edit_button.count() > 0:
edit_button.first.click()
# Update content
content_field = auth_page.locator(
"textarea[name='content'], textarea[name='review']"
)
content_field = auth_page.locator("textarea[name='content'], textarea[name='review']")
content_field.first.fill("Updated review content from E2E test")
auth_page.get_by_role("button", name="Save").click()
@@ -204,9 +176,7 @@ class TestReviewEditing:
class TestReviewModeration:
"""E2E tests for review moderation."""
def test__moderator__sees_moderation_actions(
self, mod_page: Page, live_server, parks_data
):
def test__moderator__sees_moderation_actions(self, mod_page: Page, live_server, parks_data):
"""Test moderator sees moderation actions on reviews."""
park = parks_data[0]
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
@@ -216,9 +186,7 @@ class TestReviewModeration:
reviews_tab.click()
# Moderator should see moderation buttons
mod_actions = mod_page.locator(
"button:has-text('Remove'), button:has-text('Flag'), [data-testid='mod-action']"
)
mod_actions = mod_page.locator("button:has-text('Remove'), button:has-text('Flag'), [data-testid='mod-action']")
if mod_actions.count() > 0:
expect(mod_actions.first).to_be_visible()
@@ -259,16 +227,12 @@ class TestReviewVoting:
reviews_tab.click()
# Look for helpful/upvote buttons
vote_buttons = page.locator(
"button:has-text('Helpful'), button[aria-label*='helpful'], .vote-button"
)
vote_buttons = page.locator("button:has-text('Helpful'), button[aria-label*='helpful'], .vote-button")
if vote_buttons.count() > 0:
expect(vote_buttons.first).to_be_visible()
def test__vote__authenticated__registers_vote(
self, auth_page: Page, live_server, parks_data
):
def test__vote__authenticated__registers_vote(self, auth_page: Page, live_server, parks_data):
"""Test authenticated user can vote on review."""
park = parks_data[0]
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
@@ -277,9 +241,7 @@ class TestReviewVoting:
if reviews_tab.count() > 0:
reviews_tab.click()
helpful_button = auth_page.locator(
"button:has-text('Helpful'), button[aria-label*='helpful']"
)
helpful_button = auth_page.locator("button:has-text('Helpful'), button[aria-label*='helpful']")
if helpful_button.count() > 0:
helpful_button.first.click()
@@ -298,34 +260,24 @@ class TestRideReviews:
page.goto(f"{live_server.url}/rides/{ride.slug}/")
# Reviews section should be present
reviews_section = page.locator(
"[data-testid='reviews'], #reviews, .reviews-section"
)
reviews_section = page.locator("[data-testid='reviews'], #reviews, .reviews-section")
if reviews_section.count() > 0:
expect(reviews_section.first).to_be_visible()
def test__ride_review__includes_ride_experience_fields(
self, auth_page: Page, live_server, rides_data
):
def test__ride_review__includes_ride_experience_fields(self, auth_page: Page, live_server, rides_data):
"""Test ride review form includes experience fields."""
ride = rides_data[0]
auth_page.goto(f"{live_server.url}/rides/{ride.slug}/")
write_review = auth_page.locator(
"button:has-text('Write Review'), a:has-text('Write Review')"
)
write_review = auth_page.locator("button:has-text('Write Review'), a:has-text('Write Review')")
if write_review.count() > 0:
write_review.first.click()
# Ride-specific fields
intensity_field = auth_page.locator(
"select[name='intensity'], input[name='intensity']"
)
auth_page.locator(
"input[name='wait_time'], select[name='wait_time']"
)
intensity_field = auth_page.locator("select[name='intensity'], input[name='intensity']")
auth_page.locator("input[name='wait_time'], select[name='wait_time']")
# At least one experience field should be present
if intensity_field.count() > 0:
@@ -345,9 +297,7 @@ class TestReviewFiltering:
if reviews_tab.count() > 0:
reviews_tab.click()
sort_select = page.locator(
"select[name='sort'], [data-testid='sort-reviews']"
)
sort_select = page.locator("select[name='sort'], [data-testid='sort-reviews']")
if sort_select.count() > 0:
sort_select.first.select_option("date")
@@ -362,9 +312,7 @@ class TestReviewFiltering:
if reviews_tab.count() > 0:
reviews_tab.click()
rating_filter = page.locator(
"select[name='rating'], [data-testid='rating-filter']"
)
rating_filter = page.locator("select[name='rating'], [data-testid='rating-filter']")
if rating_filter.count() > 0:
rating_filter.first.select_option("5")

View File

@@ -44,9 +44,7 @@ class TestUserRegistration:
# Should redirect to success page or login
page.wait_for_url("**/*", timeout=5000)
def test__registration__duplicate_username__shows_error(
self, page: Page, live_server, regular_user
):
def test__registration__duplicate_username__shows_error(self, page: Page, live_server, regular_user):
"""Test registration with duplicate username shows error."""
page.goto(f"{live_server.url}/accounts/signup/")
@@ -100,9 +98,7 @@ class TestUserLogin:
expect(page.get_by_label("Password")).to_be_visible()
expect(page.get_by_role("button", name="Sign In")).to_be_visible()
def test__login__valid_credentials__authenticates(
self, page: Page, live_server, regular_user
):
def test__login__valid_credentials__authenticates(self, page: Page, live_server, regular_user):
"""Test login with valid credentials authenticates user."""
page.goto(f"{live_server.url}/accounts/login/")
@@ -130,9 +126,7 @@ class TestUserLogin:
"""Test login page has remember me checkbox."""
page.goto(f"{live_server.url}/accounts/login/")
remember_me = page.locator(
"input[name='remember'], input[type='checkbox'][id*='remember']"
)
remember_me = page.locator("input[name='remember'], input[type='checkbox'][id*='remember']")
if remember_me.count() > 0:
expect(remember_me.first).to_be_visible()
@@ -147,9 +141,7 @@ class TestUserLogout:
# User is already logged in via auth_page fixture
# Find and click logout button/link
logout = auth_page.locator(
"a[href*='logout'], button:has-text('Log Out'), button:has-text('Sign Out')"
)
logout = auth_page.locator("a[href*='logout'], button:has-text('Log Out'), button:has-text('Sign Out')")
if logout.count() > 0:
logout.first.click()
@@ -172,14 +164,10 @@ class TestPasswordReset:
"""Test password reset page displays the form."""
page.goto(f"{live_server.url}/accounts/password/reset/")
email_input = page.locator(
"input[type='email'], input[name='email']"
)
email_input = page.locator("input[type='email'], input[name='email']")
expect(email_input.first).to_be_visible()
def test__password_reset__valid_email__shows_confirmation(
self, page: Page, live_server, regular_user
):
def test__password_reset__valid_email__shows_confirmation(self, page: Page, live_server, regular_user):
"""Test password reset with valid email shows confirmation."""
page.goto(f"{live_server.url}/accounts/password/reset/")
@@ -192,9 +180,7 @@ class TestPasswordReset:
page.wait_for_timeout(500)
# Look for success message or confirmation page
success = page.locator(
".success, .alert-success, [role='alert']"
)
success = page.locator(".success, .alert-success, [role='alert']")
# Or check URL changed to done page
if success.count() == 0:
@@ -216,9 +202,7 @@ class TestUserProfile:
"""Test profile page has edit profile link/button."""
auth_page.goto(f"{live_server.url}/accounts/profile/")
edit_link = auth_page.locator(
"a[href*='edit'], button:has-text('Edit')"
)
edit_link = auth_page.locator("a[href*='edit'], button:has-text('Edit')")
if edit_link.count() > 0:
expect(edit_link.first).to_be_visible()
@@ -228,9 +212,7 @@ class TestUserProfile:
auth_page.goto(f"{live_server.url}/accounts/profile/edit/")
# Find bio/about field if present
bio_field = auth_page.locator(
"textarea[name='bio'], textarea[name='about']"
)
bio_field = auth_page.locator("textarea[name='bio'], textarea[name='about']")
if bio_field.count() > 0:
bio_field.first.fill("Updated bio from E2E test")
@@ -245,18 +227,14 @@ class TestUserProfile:
class TestProtectedRoutes:
"""E2E tests for protected route access."""
def test__protected_route__unauthenticated__redirects_to_login(
self, page: Page, live_server
):
def test__protected_route__unauthenticated__redirects_to_login(self, page: Page, live_server):
"""Test accessing protected route redirects to login."""
page.goto(f"{live_server.url}/accounts/profile/")
# Should redirect to login
expect(page).to_have_url("**/login/**")
def test__protected_route__authenticated__allows_access(
self, auth_page: Page, live_server
):
def test__protected_route__authenticated__allows_access(self, auth_page: Page, live_server):
"""Test authenticated user can access protected routes."""
auth_page.goto(f"{live_server.url}/accounts/profile/")
@@ -270,9 +248,7 @@ class TestProtectedRoutes:
# Should show login or forbidden
# Admin login page or 403
def test__moderator_route__moderator__allows_access(
self, mod_page: Page, live_server
):
def test__moderator_route__moderator__allows_access(self, mod_page: Page, live_server):
"""Test moderator can access moderation routes."""
mod_page.goto(f"{live_server.url}/moderation/")