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

@@ -33,12 +33,14 @@ try:
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
HAS_SELENIUM = True
except ImportError:
HAS_SELENIUM = False
try:
from axe_selenium_python import Axe
HAS_AXE = True
except ImportError:
HAS_AXE = False
@@ -53,7 +55,7 @@ def skip_if_no_browser():
return unittest.skip("Selenium not installed")
if not HAS_AXE:
return unittest.skip("axe-selenium-python not installed")
if os.environ.get('CI') and not os.environ.get('BROWSER_TESTS'):
if os.environ.get("CI") and not os.environ.get("BROWSER_TESTS"):
return unittest.skip("Browser tests disabled in CI")
return lambda func: func
@@ -73,14 +75,12 @@ class AccessibilityTestMixin:
dict: Axe results containing violations and passes
"""
if url_name:
url = f'{self.live_server_url}{reverse(url_name)}'
url = f"{self.live_server_url}{reverse(url_name)}"
elif not url:
raise ValueError("Either url_name or url must be provided")
self.driver.get(url)
WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located((By.TAG_NAME, "body"))
)
WebDriverWait(self.driver, 10).until(EC.presence_of_element_located((By.TAG_NAME, "body")))
axe = Axe(self.driver)
axe.inject()
@@ -96,20 +96,13 @@ class AccessibilityTestMixin:
results: Axe audit results
page_name: Name of page for error messages
"""
critical_violations = [
v for v in results.get('violations', [])
if v.get('impact') in ('critical', 'serious')
]
critical_violations = [v for v in results.get("violations", []) if v.get("impact") in ("critical", "serious")]
if critical_violations:
violation_details = "\n".join([
f"- {v['id']}: {v['description']} (impact: {v['impact']})"
for v in critical_violations
])
self.fail(
f"Critical accessibility violations found on {page_name}:\n"
f"{violation_details}"
violation_details = "\n".join(
[f"- {v['id']}: {v['description']} (impact: {v['impact']})" for v in critical_violations]
)
self.fail(f"Critical accessibility violations found on {page_name}:\n" f"{violation_details}")
def assert_wcag_aa_compliant(self, results, page_name="page"):
"""
@@ -119,17 +112,13 @@ class AccessibilityTestMixin:
results: Axe audit results
page_name: Name of page for error messages
"""
violations = results.get('violations', [])
violations = results.get("violations", [])
if violations:
violation_details = "\n".join([
f"- {v['id']}: {v['description']} (impact: {v['impact']})"
for v in violations
])
self.fail(
f"WCAG 2.1 AA violations found on {page_name}:\n"
f"{violation_details}"
violation_details = "\n".join(
[f"- {v['id']}: {v['description']} (impact: {v['impact']})" for v in violations]
)
self.fail(f"WCAG 2.1 AA violations found on {page_name}:\n" f"{violation_details}")
@skip_if_no_browser()
@@ -148,11 +137,11 @@ class WCAGComplianceTests(AccessibilityTestMixin, LiveServerTestCase):
# Configure Chrome for headless testing
chrome_options = Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--window-size=1920,1080')
chrome_options.add_argument("--headless")
chrome_options.add_argument("--no-sandbox")
chrome_options.add_argument("--disable-dev-shm-usage")
chrome_options.add_argument("--disable-gpu")
chrome_options.add_argument("--window-size=1920,1080")
try:
cls.driver = webdriver.Chrome(options=chrome_options)
@@ -162,38 +151,38 @@ class WCAGComplianceTests(AccessibilityTestMixin, LiveServerTestCase):
@classmethod
def tearDownClass(cls):
if hasattr(cls, 'driver'):
if hasattr(cls, "driver"):
cls.driver.quit()
super().tearDownClass()
def test_homepage_accessibility(self):
"""Test homepage WCAG 2.1 AA compliance."""
results = self.run_axe_audit(url_name='home')
results = self.run_axe_audit(url_name="home")
self.assert_no_critical_violations(results, "homepage")
def test_park_list_accessibility(self):
"""Test park list page WCAG 2.1 AA compliance."""
results = self.run_axe_audit(url_name='parks:park_list')
results = self.run_axe_audit(url_name="parks:park_list")
self.assert_no_critical_violations(results, "park list")
def test_ride_list_accessibility(self):
"""Test ride list page WCAG 2.1 AA compliance."""
results = self.run_axe_audit(url_name='rides:global_ride_list')
results = self.run_axe_audit(url_name="rides:global_ride_list")
self.assert_no_critical_violations(results, "ride list")
def test_manufacturer_list_accessibility(self):
"""Test manufacturer list page WCAG 2.1 AA compliance."""
results = self.run_axe_audit(url_name='rides:manufacturer_list')
results = self.run_axe_audit(url_name="rides:manufacturer_list")
self.assert_no_critical_violations(results, "manufacturer list")
def test_login_page_accessibility(self):
"""Test login page WCAG 2.1 AA compliance."""
results = self.run_axe_audit(url_name='account_login')
results = self.run_axe_audit(url_name="account_login")
self.assert_no_critical_violations(results, "login page")
def test_signup_page_accessibility(self):
"""Test signup page WCAG 2.1 AA compliance."""
results = self.run_axe_audit(url_name='account_signup')
results = self.run_axe_audit(url_name="account_signup")
self.assert_no_critical_violations(results, "signup page")
@@ -207,77 +196,66 @@ class HTMLAccessibilityTests(TestCase):
def test_homepage_has_main_landmark(self):
"""Verify homepage has a main landmark."""
response = self.client.get(reverse('home'))
self.assertContains(response, '<main')
response = self.client.get(reverse("home"))
self.assertContains(response, "<main")
def test_homepage_has_skip_link(self):
"""Verify homepage has skip to content link."""
response = self.client.get(reverse('home'))
response = self.client.get(reverse("home"))
# Skip link should be present
self.assertContains(response, 'skip', msg_prefix="Should have skip link")
self.assertContains(response, "skip", msg_prefix="Should have skip link")
def test_navigation_has_aria_labels(self):
"""Verify navigation elements have ARIA labels."""
response = self.client.get(reverse('home'))
response = self.client.get(reverse("home"))
# Header should have aria-label or be wrapped in nav
self.assertTrue(
b'aria-label' in response.content or b'<nav' in response.content,
"Navigation should have ARIA attributes"
b"aria-label" in response.content or b"<nav" in response.content, "Navigation should have ARIA attributes"
)
def test_images_have_alt_text(self):
"""Verify images have alt attributes."""
response = self.client.get(reverse('home'))
content = response.content.decode('utf-8')
response = self.client.get(reverse("home"))
content = response.content.decode("utf-8")
# Find all img tags
import re
img_tags = re.findall(r'<img[^>]*>', content)
img_tags = re.findall(r"<img[^>]*>", content)
for img in img_tags:
self.assertIn(
'alt=',
img,
f"Image missing alt attribute: {img[:100]}"
)
self.assertIn("alt=", img, f"Image missing alt attribute: {img[:100]}")
def test_form_fields_have_labels(self):
"""Verify form fields have associated labels."""
response = self.client.get(reverse('account_login'))
content = response.content.decode('utf-8')
response = self.client.get(reverse("account_login"))
content = response.content.decode("utf-8")
# Find input elements (excluding hidden and submit)
import re
inputs = re.findall(
r'<input[^>]*type=["\'](?!hidden|submit)[^"\']*["\'][^>]*>',
content
)
inputs = re.findall(r'<input[^>]*type=["\'](?!hidden|submit)[^"\']*["\'][^>]*>', content)
for inp in inputs:
# Each input should have id attribute for label association
self.assertTrue(
'id=' in inp or 'aria-label' in inp,
f"Input missing id or aria-label: {inp[:100]}"
)
self.assertTrue("id=" in inp or "aria-label" in inp, f"Input missing id or aria-label: {inp[:100]}")
def test_buttons_are_accessible(self):
"""Verify buttons have accessible names."""
response = self.client.get(reverse('home'))
content = response.content.decode('utf-8')
response = self.client.get(reverse("home"))
content = response.content.decode("utf-8")
import re
# Find button elements
buttons = re.findall(r'<button[^>]*>.*?</button>', content, re.DOTALL)
buttons = re.findall(r"<button[^>]*>.*?</button>", content, re.DOTALL)
for button in buttons:
# Button should have text content or aria-label
has_text = bool(re.search(r'>([^<]+)<', button))
has_aria = 'aria-label' in button
has_text = bool(re.search(r">([^<]+)<", button))
has_aria = "aria-label" in button
self.assertTrue(
has_text or has_aria,
f"Button missing accessible name: {button[:100]}"
)
self.assertTrue(has_text or has_aria, f"Button missing accessible name: {button[:100]}")
class KeyboardNavigationTests(TestCase):
@@ -289,49 +267,35 @@ class KeyboardNavigationTests(TestCase):
def test_interactive_elements_are_focusable(self):
"""Verify interactive elements don't have tabindex=-1."""
response = self.client.get(reverse('home'))
content = response.content.decode('utf-8')
response = self.client.get(reverse("home"))
content = response.content.decode("utf-8")
# Links and buttons should not have tabindex=-1 (unless intentionally hidden)
import re
problematic = re.findall(
r'<(a|button)[^>]*tabindex=["\']?-1["\']?[^>]*>',
content
)
problematic = re.findall(r'<(a|button)[^>]*tabindex=["\']?-1["\']?[^>]*>', content)
# Filter out elements that are legitimately hidden
for elem in problematic:
self.assertIn(
'aria-hidden',
elem,
f"Interactive element has tabindex=-1 without aria-hidden: {elem}"
)
self.assertIn("aria-hidden", elem, f"Interactive element has tabindex=-1 without aria-hidden: {elem}")
def test_modals_have_escape_handler(self):
"""Verify modal templates include escape key handling."""
from django.template.loader import get_template
template = get_template('components/modals/modal_inner.html')
template = get_template("components/modals/modal_inner.html")
source = template.template.source
self.assertIn(
'escape',
source.lower(),
"Modal should handle Escape key"
)
self.assertIn("escape", source.lower(), "Modal should handle Escape key")
def test_dropdowns_have_keyboard_support(self):
"""Verify dropdown menus support keyboard navigation."""
response = self.client.get(reverse('home'))
content = response.content.decode('utf-8')
response = self.client.get(reverse("home"))
content = response.content.decode("utf-8")
# Check for aria-expanded on dropdown triggers
if 'dropdown' in content.lower() or 'menu' in content.lower():
self.assertIn(
'aria-expanded',
content,
"Dropdown should have aria-expanded attribute"
)
if "dropdown" in content.lower() or "menu" in content.lower():
self.assertIn("aria-expanded", content, "Dropdown should have aria-expanded attribute")
class ARIAAttributeTests(TestCase):
@@ -343,42 +307,26 @@ class ARIAAttributeTests(TestCase):
"""Verify modal has role=dialog."""
from django.template.loader import get_template
template = get_template('components/modals/modal_inner.html')
template = get_template("components/modals/modal_inner.html")
source = template.template.source
self.assertIn(
'role="dialog"',
source,
"Modal should have role=dialog"
)
self.assertIn('role="dialog"', source, "Modal should have role=dialog")
def test_modal_has_aria_modal(self):
"""Verify modal has aria-modal=true."""
from django.template.loader import get_template
template = get_template('components/modals/modal_inner.html')
template = get_template("components/modals/modal_inner.html")
source = template.template.source
self.assertIn(
'aria-modal="true"',
source,
"Modal should have aria-modal=true"
)
self.assertIn('aria-modal="true"', source, "Modal should have aria-modal=true")
def test_breadcrumb_has_navigation_role(self):
"""Verify breadcrumbs use nav element with aria-label."""
from django.template.loader import get_template
template = get_template('components/navigation/breadcrumbs.html')
template = get_template("components/navigation/breadcrumbs.html")
source = template.template.source
self.assertIn(
'<nav',
source,
"Breadcrumbs should use nav element"
)
self.assertIn(
'aria-label',
source,
"Breadcrumbs nav should have aria-label"
)
self.assertIn("<nav", source, "Breadcrumbs should use nav element")
self.assertIn("aria-label", source, "Breadcrumbs nav should have aria-label")

View File

@@ -32,63 +32,47 @@ class TestLoginAPIView(EnhancedAPITestCase):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.user.set_password('testpass123')
self.user.set_password("testpass123")
self.user.save()
self.url = '/api/v1/auth/login/'
self.url = "/api/v1/auth/login/"
def test__login__with_valid_credentials__returns_tokens(self):
"""Test successful login returns JWT tokens."""
response = self.client.post(self.url, {
'username': self.user.username,
'password': 'testpass123'
})
response = self.client.post(self.url, {"username": self.user.username, "password": "testpass123"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('access', response.data)
self.assertIn('refresh', response.data)
self.assertIn('user', response.data)
self.assertIn("access", response.data)
self.assertIn("refresh", response.data)
self.assertIn("user", response.data)
def test__login__with_email__returns_tokens(self):
"""Test login with email instead of username."""
response = self.client.post(self.url, {
'username': self.user.email,
'password': 'testpass123'
})
response = self.client.post(self.url, {"username": self.user.email, "password": "testpass123"})
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test__login__with_invalid_password__returns_400(self):
"""Test login with wrong password returns error."""
response = self.client.post(self.url, {
'username': self.user.username,
'password': 'wrongpassword'
})
response = self.client.post(self.url, {"username": self.user.username, "password": "wrongpassword"})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('error', response.data)
self.assertIn("detail", response.data)
def test__login__with_nonexistent_user__returns_400(self):
"""Test login with nonexistent username returns error."""
response = self.client.post(self.url, {
'username': 'nonexistentuser',
'password': 'testpass123'
})
response = self.client.post(self.url, {"username": "nonexistentuser", "password": "testpass123"})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__login__with_missing_username__returns_400(self):
"""Test login without username returns error."""
response = self.client.post(self.url, {
'password': 'testpass123'
})
response = self.client.post(self.url, {"password": "testpass123"})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__login__with_missing_password__returns_400(self):
"""Test login without password returns error."""
response = self.client.post(self.url, {
'username': self.user.username
})
response = self.client.post(self.url, {"username": self.user.username})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -103,10 +87,7 @@ class TestLoginAPIView(EnhancedAPITestCase):
self.user.is_active = False
self.user.save()
response = self.client.post(self.url, {
'username': self.user.username,
'password': 'testpass123'
})
response = self.client.post(self.url, {"username": self.user.username, "password": "testpass123"})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -117,12 +98,12 @@ class TestSignupAPIView(EnhancedAPITestCase):
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.url = '/api/v1/auth/signup/'
self.url = "/api/v1/auth/signup/"
self.valid_data = {
'username': 'newuser',
'email': 'newuser@example.com',
'password1': 'ComplexPass123!',
'password2': 'ComplexPass123!'
"username": "newuser",
"email": "newuser@example.com",
"password1": "ComplexPass123!",
"password2": "ComplexPass123!",
}
def test__signup__with_valid_data__creates_user(self):
@@ -133,10 +114,10 @@ class TestSignupAPIView(EnhancedAPITestCase):
def test__signup__with_existing_username__returns_400(self):
"""Test signup with existing username returns error."""
UserFactory(username='existinguser')
UserFactory(username="existinguser")
data = self.valid_data.copy()
data['username'] = 'existinguser'
data["username"] = "existinguser"
response = self.client.post(self.url, data)
@@ -144,10 +125,10 @@ class TestSignupAPIView(EnhancedAPITestCase):
def test__signup__with_existing_email__returns_400(self):
"""Test signup with existing email returns error."""
UserFactory(email='existing@example.com')
UserFactory(email="existing@example.com")
data = self.valid_data.copy()
data['email'] = 'existing@example.com'
data["email"] = "existing@example.com"
response = self.client.post(self.url, data)
@@ -156,7 +137,7 @@ class TestSignupAPIView(EnhancedAPITestCase):
def test__signup__with_password_mismatch__returns_400(self):
"""Test signup with mismatched passwords returns error."""
data = self.valid_data.copy()
data['password2'] = 'DifferentPass123!'
data["password2"] = "DifferentPass123!"
response = self.client.post(self.url, data)
@@ -165,8 +146,8 @@ class TestSignupAPIView(EnhancedAPITestCase):
def test__signup__with_weak_password__returns_400(self):
"""Test signup with weak password returns error."""
data = self.valid_data.copy()
data['password1'] = '123'
data['password2'] = '123'
data["password1"] = "123"
data["password2"] = "123"
response = self.client.post(self.url, data)
@@ -175,7 +156,7 @@ class TestSignupAPIView(EnhancedAPITestCase):
def test__signup__with_invalid_email__returns_400(self):
"""Test signup with invalid email returns error."""
data = self.valid_data.copy()
data['email'] = 'notanemail'
data["email"] = "notanemail"
response = self.client.post(self.url, data)
@@ -183,7 +164,7 @@ class TestSignupAPIView(EnhancedAPITestCase):
def test__signup__with_missing_fields__returns_400(self):
"""Test signup with missing required fields returns error."""
response = self.client.post(self.url, {'username': 'onlyusername'})
response = self.client.post(self.url, {"username": "onlyusername"})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -195,7 +176,7 @@ class TestLogoutAPIView(EnhancedAPITestCase):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.url = '/api/v1/auth/logout/'
self.url = "/api/v1/auth/logout/"
def test__logout__authenticated_user__returns_success(self):
"""Test successful logout for authenticated user."""
@@ -204,7 +185,7 @@ class TestLogoutAPIView(EnhancedAPITestCase):
response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('message', response.data)
self.assertIn("detail", response.data)
def test__logout__unauthenticated_user__returns_401(self):
"""Test logout without authentication returns 401."""
@@ -217,7 +198,7 @@ class TestLogoutAPIView(EnhancedAPITestCase):
self.client.force_authenticate(user=self.user)
# Simulate providing a refresh token
response = self.client.post(self.url, {'refresh': 'dummy-token'})
response = self.client.post(self.url, {"refresh": "dummy-token"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -229,7 +210,7 @@ class TestCurrentUserAPIView(EnhancedAPITestCase):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.url = '/api/v1/auth/user/'
self.url = "/api/v1/auth/user/"
def test__current_user__authenticated__returns_user_data(self):
"""Test getting current user data when authenticated."""
@@ -238,7 +219,7 @@ class TestCurrentUserAPIView(EnhancedAPITestCase):
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['username'], self.user.username)
self.assertEqual(response.data["username"], self.user.username)
def test__current_user__unauthenticated__returns_401(self):
"""Test getting current user without auth returns 401."""
@@ -254,18 +235,18 @@ class TestPasswordResetAPIView(EnhancedAPITestCase):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.url = '/api/v1/auth/password/reset/'
self.url = "/api/v1/auth/password/reset/"
def test__password_reset__with_valid_email__returns_success(self):
"""Test password reset request with valid email."""
response = self.client.post(self.url, {'email': self.user.email})
response = self.client.post(self.url, {"email": self.user.email})
# Should return success (don't reveal if email exists)
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test__password_reset__with_nonexistent_email__returns_success(self):
"""Test password reset with nonexistent email returns success (security)."""
response = self.client.post(self.url, {'email': 'nonexistent@example.com'})
response = self.client.post(self.url, {"email": "nonexistent@example.com"})
# Should return success to not reveal email existence
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
@@ -278,7 +259,7 @@ class TestPasswordResetAPIView(EnhancedAPITestCase):
def test__password_reset__with_invalid_email_format__returns_400(self):
"""Test password reset with invalid email format returns error."""
response = self.client.post(self.url, {'email': 'notanemail'})
response = self.client.post(self.url, {"email": "notanemail"})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -290,19 +271,22 @@ class TestPasswordChangeAPIView(EnhancedAPITestCase):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.user.set_password('oldpassword123')
self.user.set_password("oldpassword123")
self.user.save()
self.url = '/api/v1/auth/password/change/'
self.url = "/api/v1/auth/password/change/"
def test__password_change__with_valid_data__changes_password(self):
"""Test password change with valid data."""
self.client.force_authenticate(user=self.user)
response = self.client.post(self.url, {
'old_password': 'oldpassword123',
'new_password1': 'NewComplexPass123!',
'new_password2': 'NewComplexPass123!'
})
response = self.client.post(
self.url,
{
"old_password": "oldpassword123",
"new_password1": "NewComplexPass123!",
"new_password2": "NewComplexPass123!",
},
)
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
@@ -310,21 +294,27 @@ class TestPasswordChangeAPIView(EnhancedAPITestCase):
"""Test password change with wrong old password."""
self.client.force_authenticate(user=self.user)
response = self.client.post(self.url, {
'old_password': 'wrongpassword',
'new_password1': 'NewComplexPass123!',
'new_password2': 'NewComplexPass123!'
})
response = self.client.post(
self.url,
{
"old_password": "wrongpassword",
"new_password1": "NewComplexPass123!",
"new_password2": "NewComplexPass123!",
},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__password_change__unauthenticated__returns_401(self):
"""Test password change without authentication."""
response = self.client.post(self.url, {
'old_password': 'oldpassword123',
'new_password1': 'NewComplexPass123!',
'new_password2': 'NewComplexPass123!'
})
response = self.client.post(
self.url,
{
"old_password": "oldpassword123",
"new_password1": "NewComplexPass123!",
"new_password2": "NewComplexPass123!",
},
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@@ -335,7 +325,7 @@ class TestSocialProvidersAPIView(EnhancedAPITestCase):
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.url = '/api/v1/auth/social/providers/'
self.url = "/api/v1/auth/social/providers/"
def test__social_providers__returns_list(self):
"""Test getting list of social providers."""
@@ -352,7 +342,7 @@ class TestAuthStatusAPIView(EnhancedAPITestCase):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.url = '/api/v1/auth/status/'
self.url = "/api/v1/auth/status/"
def test__auth_status__authenticated__returns_authenticated_true(self):
"""Test auth status for authenticated user."""
@@ -361,15 +351,15 @@ class TestAuthStatusAPIView(EnhancedAPITestCase):
response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data.get('authenticated'))
self.assertIsNotNone(response.data.get('user'))
self.assertTrue(response.data.get("authenticated"))
self.assertIsNotNone(response.data.get("user"))
def test__auth_status__unauthenticated__returns_authenticated_false(self):
"""Test auth status for unauthenticated user."""
response = self.client.post(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertFalse(response.data.get('authenticated'))
self.assertFalse(response.data.get("authenticated"))
class TestAvailableProvidersAPIView(EnhancedAPITestCase):
@@ -378,7 +368,7 @@ class TestAvailableProvidersAPIView(EnhancedAPITestCase):
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.url = '/api/v1/auth/social/available/'
self.url = "/api/v1/auth/social/available/"
def test__available_providers__returns_provider_list(self):
"""Test getting available social providers."""
@@ -395,7 +385,7 @@ class TestConnectedProvidersAPIView(EnhancedAPITestCase):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.url = '/api/v1/auth/social/connected/'
self.url = "/api/v1/auth/social/connected/"
def test__connected_providers__authenticated__returns_list(self):
"""Test getting connected providers for authenticated user."""
@@ -423,9 +413,7 @@ class TestConnectProviderAPIView(EnhancedAPITestCase):
def test__connect_provider__unauthenticated__returns_401(self):
"""Test connecting provider without auth."""
response = self.client.post('/api/v1/auth/social/connect/google/', {
'access_token': 'dummy-token'
})
response = self.client.post("/api/v1/auth/social/connect/google/", {"access_token": "dummy-token"})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@@ -433,9 +421,7 @@ class TestConnectProviderAPIView(EnhancedAPITestCase):
"""Test connecting invalid provider."""
self.client.force_authenticate(user=self.user)
response = self.client.post('/api/v1/auth/social/connect/invalid/', {
'access_token': 'dummy-token'
})
response = self.client.post("/api/v1/auth/social/connect/invalid/", {"access_token": "dummy-token"})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -443,7 +429,7 @@ class TestConnectProviderAPIView(EnhancedAPITestCase):
"""Test connecting provider without token."""
self.client.force_authenticate(user=self.user)
response = self.client.post('/api/v1/auth/social/connect/google/', {})
response = self.client.post("/api/v1/auth/social/connect/google/", {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -458,7 +444,7 @@ class TestDisconnectProviderAPIView(EnhancedAPITestCase):
def test__disconnect_provider__unauthenticated__returns_401(self):
"""Test disconnecting provider without auth."""
response = self.client.post('/api/v1/auth/social/disconnect/google/')
response = self.client.post("/api/v1/auth/social/disconnect/google/")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@@ -466,7 +452,7 @@ class TestDisconnectProviderAPIView(EnhancedAPITestCase):
"""Test disconnecting invalid provider."""
self.client.force_authenticate(user=self.user)
response = self.client.post('/api/v1/auth/social/disconnect/invalid/')
response = self.client.post("/api/v1/auth/social/disconnect/invalid/")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -478,7 +464,7 @@ class TestSocialAuthStatusAPIView(EnhancedAPITestCase):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory()
self.url = '/api/v1/auth/social/status/'
self.url = "/api/v1/auth/social/status/"
def test__social_auth_status__authenticated__returns_status(self):
"""Test getting social auth status."""
@@ -504,7 +490,7 @@ class TestEmailVerificationAPIView(EnhancedAPITestCase):
def test__email_verification__invalid_token__returns_404(self):
"""Test email verification with invalid token."""
response = self.client.get('/api/v1/auth/verify-email/invalid-token/')
response = self.client.get("/api/v1/auth/verify-email/invalid-token/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@@ -516,7 +502,7 @@ class TestResendVerificationAPIView(EnhancedAPITestCase):
"""Set up test data."""
self.client = APIClient()
self.user = UserFactory(is_active=False)
self.url = '/api/v1/auth/resend-verification/'
self.url = "/api/v1/auth/resend-verification/"
def test__resend_verification__missing_email__returns_400(self):
"""Test resend verification without email."""
@@ -528,13 +514,13 @@ class TestResendVerificationAPIView(EnhancedAPITestCase):
"""Test resend verification for already verified user."""
active_user = UserFactory(is_active=True)
response = self.client.post(self.url, {'email': active_user.email})
response = self.client.post(self.url, {"email": active_user.email})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__resend_verification__nonexistent_email__returns_success(self):
"""Test resend verification for nonexistent email (security)."""
response = self.client.post(self.url, {'email': 'nonexistent@example.com'})
response = self.client.post(self.url, {"email": "nonexistent@example.com"})
# Should return success to not reveal email existence
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -556,35 +542,26 @@ class TestAuthAPIEdgeCases(EnhancedAPITestCase):
]
for username in special_usernames:
response = self.client.post('/api/v1/auth/login/', {
'username': username,
'password': 'testpass123'
})
response = self.client.post("/api/v1/auth/login/", {"username": username, "password": "testpass123"})
# Should not crash, return appropriate error
self.assertIn(response.status_code, [
status.HTTP_400_BAD_REQUEST,
status.HTTP_401_UNAUTHORIZED
])
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
def test__signup__with_very_long_username__handled_safely(self):
"""Test signup with very long username."""
response = self.client.post('/api/v1/auth/signup/', {
'username': 'a' * 1000,
'email': 'test@example.com',
'password1': 'ComplexPass123!',
'password2': 'ComplexPass123!'
})
response = self.client.post(
"/api/v1/auth/signup/",
{
"username": "a" * 1000,
"email": "test@example.com",
"password1": "ComplexPass123!",
"password2": "ComplexPass123!",
},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__login__with_unicode_characters__handled_safely(self):
"""Test login with unicode characters."""
response = self.client.post('/api/v1/auth/login/', {
'username': 'user\u202e',
'password': 'pass\u202e'
})
response = self.client.post("/api/v1/auth/login/", {"username": "user\u202e", "password": "pass\u202e"})
self.assertIn(response.status_code, [
status.HTTP_400_BAD_REQUEST,
status.HTTP_401_UNAUTHORIZED
])
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])

View File

@@ -25,23 +25,18 @@ class ErrorResponseFormatTestCase(TestCase):
data = response.json()
# Should have error information
self.assertTrue(
"error" in data or "detail" in data or "status" in data,
"404 response should contain error information"
"error" in data or "detail" in data or "status" in data, "404 response should contain error information"
)
def test_400_error_format(self):
"""Test that 400 validation errors follow standardized format."""
response = self.client.get(
"/api/v1/rides/hybrid/",
{"offset": "invalid"}
)
response = self.client.get("/api/v1/rides/hybrid/", {"offset": "invalid"})
if response.status_code == status.HTTP_400_BAD_REQUEST:
data = response.json()
# Should have error information
self.assertTrue(
"error" in data or "status" in data or "detail" in data,
"400 response should contain error information"
"error" in data or "status" in data or "detail" in data, "400 response should contain error information"
)
def test_500_error_format(self):
@@ -59,10 +54,7 @@ class ErrorCodeConsistencyTestCase(TestCase):
def test_validation_error_code(self):
"""Test that validation errors use consistent error codes."""
response = self.client.get(
"/api/v1/rides/hybrid/",
{"offset": "invalid"}
)
response = self.client.get("/api/v1/rides/hybrid/", {"offset": "invalid"})
if response.status_code == status.HTTP_400_BAD_REQUEST:
data = response.json()
@@ -86,10 +78,7 @@ class AuthenticationErrorTestCase(TestCase):
if response.status_code == status.HTTP_401_UNAUTHORIZED:
data = response.json()
# Should have error information
self.assertTrue(
"error" in data or "detail" in data,
"401 response should contain error information"
)
self.assertTrue("error" in data or "detail" in data, "401 response should contain error information")
def test_forbidden_error_format(self):
"""Test that forbidden errors are properly formatted."""
@@ -109,10 +98,7 @@ class ExceptionHandlerTestCase(TestCase):
from django.conf import settings
exception_handler = settings.REST_FRAMEWORK.get("EXCEPTION_HANDLER")
self.assertEqual(
exception_handler,
"apps.core.api.exceptions.custom_exception_handler"
)
self.assertEqual(exception_handler, "apps.core.api.exceptions.custom_exception_handler")
def test_throttled_error_format(self):
"""Test that throttled errors are properly formatted."""

View File

@@ -20,39 +20,24 @@ class FilterParameterNamingTestCase(TestCase):
def test_range_filter_naming_convention(self):
"""Test that range filters use {field}_min/{field}_max naming."""
# Test parks rating range filter
response = self.client.get(
"/api/v1/parks/hybrid/",
{"rating_min": 3.0, "rating_max": 5.0}
)
response = self.client.get("/api/v1/parks/hybrid/", {"rating_min": 3.0, "rating_max": 5.0})
# Should not return error for valid filter names
self.assertIn(
response.status_code,
[status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST]
)
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test_search_parameter_naming(self):
"""Test that search parameter is named consistently."""
response = self.client.get("/api/v1/parks/hybrid/", {"search": "cedar"})
self.assertIn(
response.status_code,
[status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST]
)
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test_ordering_parameter_naming(self):
"""Test that ordering parameter is named consistently."""
response = self.client.get("/api/v1/parks/hybrid/", {"ordering": "name"})
self.assertIn(
response.status_code,
[status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST]
)
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test_ordering_descending_prefix(self):
"""Test that descending ordering uses - prefix."""
response = self.client.get("/api/v1/parks/hybrid/", {"ordering": "-name"})
self.assertIn(
response.status_code,
[status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST]
)
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
class FilterBehaviorTestCase(TestCase):
@@ -64,10 +49,7 @@ class FilterBehaviorTestCase(TestCase):
def test_filter_combination_and_logic(self):
"""Test that multiple different filters use AND logic."""
response = self.client.get(
"/api/v1/parks/hybrid/",
{"rating_min": 4.0, "country": "us"}
)
response = self.client.get("/api/v1/parks/hybrid/", {"rating_min": 4.0, "country": "us"})
if response.status_code == status.HTTP_200_OK:
data = response.json()
# Results should match both criteria
@@ -75,25 +57,16 @@ class FilterBehaviorTestCase(TestCase):
def test_multi_select_filter_or_logic(self):
"""Test that multi-select filters within same field use OR logic."""
response = self.client.get(
"/api/v1/rides/hybrid/",
{"ride_type": "Coaster,Dark Ride"}
)
response = self.client.get("/api/v1/rides/hybrid/", {"ride_type": "Coaster,Dark Ride"})
if response.status_code == status.HTTP_200_OK:
data = response.json()
self.assertIn("success", data)
def test_invalid_filter_value_returns_error(self):
"""Test that invalid filter values return appropriate error."""
response = self.client.get(
"/api/v1/parks/hybrid/",
{"rating_min": "not_a_number"}
)
response = self.client.get("/api/v1/parks/hybrid/", {"rating_min": "not_a_number"})
# Could be 200 (ignored) or 400 (validation error)
self.assertIn(
response.status_code,
[status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST]
)
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
class FilterMetadataTestCase(TestCase):
@@ -116,9 +89,11 @@ class FilterMetadataTestCase(TestCase):
metadata = data["data"]
# Should have categorical and/or ranges
self.assertTrue(
"categorical" in metadata or "ranges" in metadata or
"total_count" in metadata or "ordering_options" in metadata,
"Filter metadata should contain filter options"
"categorical" in metadata
or "ranges" in metadata
or "total_count" in metadata
or "ordering_options" in metadata,
"Filter metadata should contain filter options",
)
def test_rides_filter_metadata_structure(self):

View File

@@ -112,7 +112,4 @@ class HybridPaginationTestCase(TestCase):
if response.status_code == status.HTTP_400_BAD_REQUEST:
data = response.json()
# Should have error information
self.assertTrue(
"error" in data or "status" in data,
"Error response should contain error information"
)
self.assertTrue("error" in data or "status" in data, "Error response should contain error information")

View File

@@ -38,13 +38,13 @@ class TestParkPhotoViewSetList(EnhancedAPITestCase):
self.user = UserFactory()
self.park = ParkFactory()
@patch('apps.parks.models.ParkPhoto.objects')
@patch("apps.parks.models.ParkPhoto.objects")
def test__list_park_photos__unauthenticated__can_access(self, mock_queryset):
"""Test that unauthenticated users can access park photo list."""
# Mock the queryset
mock_queryset.select_related.return_value.filter.return_value.order_by.return_value = []
url = f'/api/v1/parks/{self.park.id}/photos/'
url = f"/api/v1/parks/{self.park.id}/photos/"
response = self.client.get(url)
# Should allow access (AllowAny permission for list)
@@ -52,7 +52,7 @@ class TestParkPhotoViewSetList(EnhancedAPITestCase):
def test__list_park_photos__with_invalid_park__returns_empty_or_404(self):
"""Test listing photos for non-existent park."""
url = '/api/v1/parks/99999/photos/'
url = "/api/v1/parks/99999/photos/"
response = self.client.get(url)
# Should handle gracefully
@@ -71,7 +71,7 @@ class TestParkPhotoViewSetCreate(EnhancedAPITestCase):
def test__create_park_photo__unauthenticated__returns_401(self):
"""Test that unauthenticated users cannot create photos."""
url = f'/api/v1/parks/{self.park.id}/photos/'
url = f"/api/v1/parks/{self.park.id}/photos/"
response = self.client.post(url, {})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@@ -79,7 +79,7 @@ class TestParkPhotoViewSetCreate(EnhancedAPITestCase):
def test__create_park_photo__authenticated_without_data__returns_400(self):
"""Test that creating photo without required data returns 400."""
self.client.force_authenticate(user=self.user)
url = f'/api/v1/parks/{self.park.id}/photos/'
url = f"/api/v1/parks/{self.park.id}/photos/"
response = self.client.post(url, {})
@@ -88,9 +88,9 @@ class TestParkPhotoViewSetCreate(EnhancedAPITestCase):
def test__create_park_photo__invalid_park__returns_error(self):
"""Test creating photo for non-existent park."""
self.client.force_authenticate(user=self.user)
url = '/api/v1/parks/99999/photos/'
url = "/api/v1/parks/99999/photos/"
response = self.client.post(url, {'caption': 'Test'})
response = self.client.post(url, {"caption": "Test"})
# Should return 400 or 404 for invalid park
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_404_NOT_FOUND])
@@ -106,7 +106,7 @@ class TestParkPhotoViewSetRetrieve(EnhancedAPITestCase):
def test__retrieve_park_photo__not_found__returns_404(self):
"""Test retrieving non-existent photo returns 404."""
url = f'/api/v1/parks/{self.park.id}/photos/99999/'
url = f"/api/v1/parks/{self.park.id}/photos/99999/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@@ -125,8 +125,8 @@ class TestParkPhotoViewSetUpdate(EnhancedAPITestCase):
def test__update_park_photo__unauthenticated__returns_401(self):
"""Test that unauthenticated users cannot update photos."""
url = f'/api/v1/parks/{self.park.id}/photos/1/'
response = self.client.patch(url, {'caption': 'Updated'})
url = f"/api/v1/parks/{self.park.id}/photos/1/"
response = self.client.patch(url, {"caption": "Updated"})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@@ -143,7 +143,7 @@ class TestParkPhotoViewSetDelete(EnhancedAPITestCase):
def test__delete_park_photo__unauthenticated__returns_401(self):
"""Test that unauthenticated users cannot delete photos."""
url = f'/api/v1/parks/{self.park.id}/photos/1/'
url = f"/api/v1/parks/{self.park.id}/photos/1/"
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@@ -161,7 +161,7 @@ class TestParkPhotoViewSetSetPrimary(EnhancedAPITestCase):
def test__set_primary__unauthenticated__returns_401(self):
"""Test that unauthenticated users cannot set primary photo."""
url = f'/api/v1/parks/{self.park.id}/photos/1/set_primary/'
url = f"/api/v1/parks/{self.park.id}/photos/1/set_primary/"
response = self.client.post(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@@ -169,7 +169,7 @@ class TestParkPhotoViewSetSetPrimary(EnhancedAPITestCase):
def test__set_primary__photo_not_found__returns_404(self):
"""Test setting primary for non-existent photo."""
self.client.force_authenticate(user=self.user)
url = f'/api/v1/parks/{self.park.id}/photos/99999/set_primary/'
url = f"/api/v1/parks/{self.park.id}/photos/99999/set_primary/"
response = self.client.post(url)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@@ -187,23 +187,23 @@ class TestParkPhotoViewSetBulkApprove(EnhancedAPITestCase):
def test__bulk_approve__unauthenticated__returns_401(self):
"""Test that unauthenticated users cannot bulk approve."""
url = f'/api/v1/parks/{self.park.id}/photos/bulk_approve/'
response = self.client.post(url, {'photo_ids': [1, 2], 'approve': True})
url = f"/api/v1/parks/{self.park.id}/photos/bulk_approve/"
response = self.client.post(url, {"photo_ids": [1, 2], "approve": True})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test__bulk_approve__non_staff__returns_403(self):
"""Test that non-staff users cannot bulk approve."""
self.client.force_authenticate(user=self.user)
url = f'/api/v1/parks/{self.park.id}/photos/bulk_approve/'
response = self.client.post(url, {'photo_ids': [1, 2], 'approve': True})
url = f"/api/v1/parks/{self.park.id}/photos/bulk_approve/"
response = self.client.post(url, {"photo_ids": [1, 2], "approve": True})
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test__bulk_approve__missing_data__returns_400(self):
"""Test bulk approve with missing required data."""
self.client.force_authenticate(user=self.staff_user)
url = f'/api/v1/parks/{self.park.id}/photos/bulk_approve/'
url = f"/api/v1/parks/{self.park.id}/photos/bulk_approve/"
response = self.client.post(url, {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -219,7 +219,7 @@ class TestParkPhotoViewSetStats(EnhancedAPITestCase):
def test__stats__unauthenticated__can_access(self):
"""Test that unauthenticated users can access stats."""
url = f'/api/v1/parks/{self.park.id}/photos/stats/'
url = f"/api/v1/parks/{self.park.id}/photos/stats/"
response = self.client.get(url)
# Stats should be accessible to all
@@ -227,7 +227,7 @@ class TestParkPhotoViewSetStats(EnhancedAPITestCase):
def test__stats__invalid_park__returns_404(self):
"""Test stats for non-existent park returns 404."""
url = '/api/v1/parks/99999/photos/stats/'
url = "/api/v1/parks/99999/photos/stats/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@@ -244,15 +244,15 @@ class TestParkPhotoViewSetSaveImage(EnhancedAPITestCase):
def test__save_image__unauthenticated__returns_401(self):
"""Test that unauthenticated users cannot save images."""
url = f'/api/v1/parks/{self.park.id}/photos/save_image/'
response = self.client.post(url, {'cloudflare_image_id': 'test-id'})
url = f"/api/v1/parks/{self.park.id}/photos/save_image/"
response = self.client.post(url, {"cloudflare_image_id": "test-id"})
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test__save_image__missing_cloudflare_id__returns_400(self):
"""Test saving image without cloudflare_image_id."""
self.client.force_authenticate(user=self.user)
url = f'/api/v1/parks/{self.park.id}/photos/save_image/'
url = f"/api/v1/parks/{self.park.id}/photos/save_image/"
response = self.client.post(url, {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -260,8 +260,8 @@ class TestParkPhotoViewSetSaveImage(EnhancedAPITestCase):
def test__save_image__invalid_park__returns_404(self):
"""Test saving image for non-existent park."""
self.client.force_authenticate(user=self.user)
url = '/api/v1/parks/99999/photos/save_image/'
response = self.client.post(url, {'cloudflare_image_id': 'test-id'})
url = "/api/v1/parks/99999/photos/save_image/"
response = self.client.post(url, {"cloudflare_image_id": "test-id"})
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@@ -273,112 +273,112 @@ class TestHybridParkAPIView(EnhancedAPITestCase):
"""Set up test data."""
self.client = APIClient()
# Create several parks for testing
self.operator = CompanyFactory(roles=['OPERATOR'])
self.operator = CompanyFactory(roles=["OPERATOR"])
self.parks = [
ParkFactory(operator=self.operator, status='OPERATING', name='Alpha Park'),
ParkFactory(operator=self.operator, status='OPERATING', name='Beta Park'),
ParkFactory(operator=self.operator, status='CLOSED_PERM', name='Gamma Park'),
ParkFactory(operator=self.operator, status="OPERATING", name="Alpha Park"),
ParkFactory(operator=self.operator, status="OPERATING", name="Beta Park"),
ParkFactory(operator=self.operator, status="CLOSED_PERM", name="Gamma Park"),
]
def test__hybrid_park_api__initial_load__returns_parks(self):
"""Test initial load returns parks with metadata."""
url = '/api/v1/parks/hybrid/'
url = "/api/v1/parks/hybrid/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data.get('success', False))
self.assertIn('data', response.data)
self.assertIn('parks', response.data['data'])
self.assertIn('total_count', response.data['data'])
self.assertIn('strategy', response.data['data'])
self.assertTrue(response.data.get("success", False))
self.assertIn("data", response.data)
self.assertIn("parks", response.data["data"])
self.assertIn("total_count", response.data["data"])
self.assertIn("strategy", response.data["data"])
def test__hybrid_park_api__with_status_filter__returns_filtered_parks(self):
"""Test filtering by status."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'status': 'OPERATING'})
url = "/api/v1/parks/hybrid/"
response = self.client.get(url, {"status": "OPERATING"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
# All returned parks should be OPERATING
for park in response.data['data']['parks']:
self.assertEqual(park['status'], 'OPERATING')
for park in response.data["data"]["parks"]:
self.assertEqual(park["status"], "OPERATING")
def test__hybrid_park_api__with_multiple_status_filter__returns_filtered_parks(self):
"""Test filtering by multiple statuses."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'status': 'OPERATING,CLOSED_PERM'})
url = "/api/v1/parks/hybrid/"
response = self.client.get(url, {"status": "OPERATING,CLOSED_PERM"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_park_api__with_search__returns_matching_parks(self):
"""Test search functionality."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'search': 'Alpha'})
url = "/api/v1/parks/hybrid/"
response = self.client.get(url, {"search": "Alpha"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Should find Alpha Park
parks = response.data['data']['parks']
park_names = [p['name'] for p in parks]
self.assertIn('Alpha Park', park_names)
parks = response.data["data"]["parks"]
park_names = [p["name"] for p in parks]
self.assertIn("Alpha Park", park_names)
def test__hybrid_park_api__with_offset__returns_progressive_data(self):
"""Test progressive loading with offset."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'offset': 0})
url = "/api/v1/parks/hybrid/"
response = self.client.get(url, {"offset": 0})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('has_more', response.data['data'])
self.assertIn("has_more", response.data["data"])
def test__hybrid_park_api__with_invalid_offset__returns_400(self):
"""Test invalid offset parameter."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'offset': 'invalid'})
url = "/api/v1/parks/hybrid/"
response = self.client.get(url, {"offset": "invalid"})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__hybrid_park_api__with_year_filters__returns_filtered_parks(self):
"""Test filtering by opening year range."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'opening_year_min': 2000, 'opening_year_max': 2024})
url = "/api/v1/parks/hybrid/"
response = self.client.get(url, {"opening_year_min": 2000, "opening_year_max": 2024})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_park_api__with_rating_filters__returns_filtered_parks(self):
"""Test filtering by rating range."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'rating_min': 5.0, 'rating_max': 10.0})
url = "/api/v1/parks/hybrid/"
response = self.client.get(url, {"rating_min": 5.0, "rating_max": 10.0})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_park_api__with_size_filters__returns_filtered_parks(self):
"""Test filtering by size range."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'size_min': 10, 'size_max': 1000})
url = "/api/v1/parks/hybrid/"
response = self.client.get(url, {"size_min": 10, "size_max": 1000})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_park_api__with_ride_count_filters__returns_filtered_parks(self):
"""Test filtering by ride count range."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'ride_count_min': 5, 'ride_count_max': 100})
url = "/api/v1/parks/hybrid/"
response = self.client.get(url, {"ride_count_min": 5, "ride_count_max": 100})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_park_api__with_coaster_count_filters__returns_filtered_parks(self):
"""Test filtering by coaster count range."""
url = '/api/v1/parks/hybrid/'
response = self.client.get(url, {'coaster_count_min': 1, 'coaster_count_max': 20})
url = "/api/v1/parks/hybrid/"
response = self.client.get(url, {"coaster_count_min": 1, "coaster_count_max": 20})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_park_api__includes_filter_metadata__on_initial_load(self):
"""Test that initial load includes filter metadata."""
url = '/api/v1/parks/hybrid/'
url = "/api/v1/parks/hybrid/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Filter metadata should be included for client-side filtering
if 'filter_metadata' in response.data.get('data', {}):
self.assertIn('filter_metadata', response.data['data'])
if "filter_metadata" in response.data.get("data", {}):
self.assertIn("filter_metadata", response.data["data"])
class TestParkFilterMetadataAPIView(EnhancedAPITestCase):
@@ -387,7 +387,7 @@ class TestParkFilterMetadataAPIView(EnhancedAPITestCase):
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.operator = CompanyFactory(roles=['OPERATOR'])
self.operator = CompanyFactory(roles=["OPERATOR"])
self.parks = [
ParkFactory(operator=self.operator),
ParkFactory(operator=self.operator),
@@ -395,32 +395,32 @@ class TestParkFilterMetadataAPIView(EnhancedAPITestCase):
def test__filter_metadata__unscoped__returns_all_metadata(self):
"""Test getting unscoped filter metadata."""
url = '/api/v1/parks/filter-metadata/'
url = "/api/v1/parks/filter-metadata/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data.get('success', False))
self.assertIn('data', response.data)
self.assertTrue(response.data.get("success", False))
self.assertIn("data", response.data)
def test__filter_metadata__scoped__returns_filtered_metadata(self):
"""Test getting scoped filter metadata."""
url = '/api/v1/parks/filter-metadata/'
response = self.client.get(url, {'scoped': 'true', 'status': 'OPERATING'})
url = "/api/v1/parks/filter-metadata/"
response = self.client.get(url, {"scoped": "true", "status": "OPERATING"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__filter_metadata__structure__contains_expected_fields(self):
"""Test that metadata contains expected structure."""
url = '/api/v1/parks/filter-metadata/'
url = "/api/v1/parks/filter-metadata/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.data.get('data', {})
data = response.data.get("data", {})
# Should contain categorical and range metadata
if data:
# These are the expected top-level keys based on the view
possible_keys = ['categorical', 'ranges', 'total_count']
possible_keys = ["categorical", "ranges", "total_count"]
for key in possible_keys:
if key in data:
self.assertIsNotNone(data[key])
@@ -464,7 +464,7 @@ class TestParkAPIQueryOptimization(EnhancedAPITestCase):
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.operator = CompanyFactory(roles=['OPERATOR'])
self.operator = CompanyFactory(roles=["OPERATOR"])
def test__park_list__uses_select_related(self):
"""Test that park list uses select_related for optimization."""
@@ -472,7 +472,7 @@ class TestParkAPIQueryOptimization(EnhancedAPITestCase):
for _i in range(5):
ParkFactory(operator=self.operator)
url = '/api/v1/parks/hybrid/'
url = "/api/v1/parks/hybrid/"
# This test verifies the query is executed without N+1
response = self.client.get(url)
@@ -483,13 +483,13 @@ class TestParkAPIQueryOptimization(EnhancedAPITestCase):
"""Test that park list handles larger datasets efficiently."""
# Create a batch of parks
for i in range(10):
ParkFactory(operator=self.operator, name=f'Park {i}')
ParkFactory(operator=self.operator, name=f"Park {i}")
url = '/api/v1/parks/hybrid/'
url = "/api/v1/parks/hybrid/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertGreaterEqual(response.data['data']['total_count'], 10)
self.assertGreaterEqual(response.data["data"]["total_count"], 10)
class TestParkAPIEdgeCases(EnhancedAPITestCase):
@@ -504,16 +504,16 @@ class TestParkAPIEdgeCases(EnhancedAPITestCase):
# Delete all parks for this test
Park.objects.all().delete()
url = '/api/v1/parks/hybrid/'
url = "/api/v1/parks/hybrid/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['data']['parks'], [])
self.assertEqual(response.data['data']['total_count'], 0)
self.assertEqual(response.data["data"]["parks"], [])
self.assertEqual(response.data["data"]["total_count"], 0)
def test__hybrid_park__special_characters_in_search__handled_safely(self):
"""Test that special characters in search are handled safely."""
url = '/api/v1/parks/hybrid/'
url = "/api/v1/parks/hybrid/"
# Test with special characters
special_searches = [
@@ -525,21 +525,24 @@ class TestParkAPIEdgeCases(EnhancedAPITestCase):
]
for search_term in special_searches:
response = self.client.get(url, {'search': search_term})
response = self.client.get(url, {"search": search_term})
# Should not crash, either 200 or error with proper message
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test__hybrid_park__extreme_filter_values__handled_safely(self):
"""Test that extreme filter values are handled safely."""
url = '/api/v1/parks/hybrid/'
url = "/api/v1/parks/hybrid/"
# Test with extreme values
response = self.client.get(url, {
'rating_min': -100,
'rating_max': 10000,
'opening_year_min': 1,
'opening_year_max': 9999,
})
response = self.client.get(
url,
{
"rating_min": -100,
"rating_max": 10000,
"opening_year_min": 1,
"opening_year_max": 9999,
},
)
# Should handle gracefully
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])

View File

@@ -44,7 +44,7 @@ class ResponseFormatTestCase(TestCase):
# Should have either 'error' or 'status' key for error responses
self.assertTrue(
"error" in data or "status" in data or "detail" in data,
"Error response should contain error information"
"Error response should contain error information",
)
def test_validation_error_format(self):

View File

@@ -45,25 +45,16 @@ class TestRideListAPIView(EnhancedAPITestCase):
park=self.park,
manufacturer=self.manufacturer,
designer=self.designer,
name='Alpha Coaster',
status='OPERATING',
category='RC'
name="Alpha Coaster",
status="OPERATING",
category="RC",
),
RideFactory(
park=self.park,
manufacturer=self.manufacturer,
name='Beta Ride',
status='OPERATING',
category='DR'
),
RideFactory(
park=self.park,
name='Gamma Coaster',
status='CLOSED_TEMP',
category='RC'
park=self.park, manufacturer=self.manufacturer, name="Beta Ride", status="OPERATING", category="DR"
),
RideFactory(park=self.park, name="Gamma Coaster", status="CLOSED_TEMP", category="RC"),
]
self.url = '/api/v1/rides/'
self.url = "/api/v1/rides/"
def test__ride_list__unauthenticated__can_access(self):
"""Test that unauthenticated users can access ride list."""
@@ -77,119 +68,109 @@ class TestRideListAPIView(EnhancedAPITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Should have pagination info
self.assertIn('results', response.data)
self.assertIn('count', response.data)
self.assertIn("results", response.data)
self.assertIn("count", response.data)
def test__ride_list__with_search__returns_matching_rides(self):
"""Test search functionality."""
response = self.client.get(self.url, {'search': 'Alpha'})
response = self.client.get(self.url, {"search": "Alpha"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Should find Alpha Coaster
results = response.data.get('results', [])
results = response.data.get("results", [])
if results:
names = [r.get('name', '') for r in results]
self.assertTrue(any('Alpha' in name for name in names))
names = [r.get("name", "") for r in results]
self.assertTrue(any("Alpha" in name for name in names))
def test__ride_list__with_park_slug__returns_filtered_rides(self):
"""Test filtering by park slug."""
response = self.client.get(self.url, {'park_slug': self.park.slug})
response = self.client.get(self.url, {"park_slug": self.park.slug})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_park_id__returns_filtered_rides(self):
"""Test filtering by park ID."""
response = self.client.get(self.url, {'park_id': self.park.id})
response = self.client.get(self.url, {"park_id": self.park.id})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_category_filter__returns_filtered_rides(self):
"""Test filtering by category."""
response = self.client.get(self.url, {'category': 'RC'})
response = self.client.get(self.url, {"category": "RC"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
# All returned rides should be roller coasters
for ride in response.data.get('results', []):
self.assertEqual(ride.get('category'), 'RC')
for ride in response.data.get("results", []):
self.assertEqual(ride.get("category"), "RC")
def test__ride_list__with_status_filter__returns_filtered_rides(self):
"""Test filtering by status."""
response = self.client.get(self.url, {'status': 'OPERATING'})
response = self.client.get(self.url, {"status": "OPERATING"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
for ride in response.data.get('results', []):
self.assertEqual(ride.get('status'), 'OPERATING')
for ride in response.data.get("results", []):
self.assertEqual(ride.get("status"), "OPERATING")
def test__ride_list__with_manufacturer_filter__returns_filtered_rides(self):
"""Test filtering by manufacturer ID."""
response = self.client.get(self.url, {'manufacturer_id': self.manufacturer.id})
response = self.client.get(self.url, {"manufacturer_id": self.manufacturer.id})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_manufacturer_slug__returns_filtered_rides(self):
"""Test filtering by manufacturer slug."""
response = self.client.get(self.url, {'manufacturer_slug': self.manufacturer.slug})
response = self.client.get(self.url, {"manufacturer_slug": self.manufacturer.slug})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_designer_filter__returns_filtered_rides(self):
"""Test filtering by designer ID."""
response = self.client.get(self.url, {'designer_id': self.designer.id})
response = self.client.get(self.url, {"designer_id": self.designer.id})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_rating_filters__returns_filtered_rides(self):
"""Test filtering by rating range."""
response = self.client.get(self.url, {'min_rating': 5, 'max_rating': 10})
response = self.client.get(self.url, {"min_rating": 5, "max_rating": 10})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_height_requirement_filters__returns_filtered_rides(self):
"""Test filtering by height requirement."""
response = self.client.get(self.url, {
'min_height_requirement': 36,
'max_height_requirement': 54
})
response = self.client.get(self.url, {"min_height_requirement": 36, "max_height_requirement": 54})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_capacity_filters__returns_filtered_rides(self):
"""Test filtering by capacity."""
response = self.client.get(self.url, {'min_capacity': 500, 'max_capacity': 3000})
response = self.client.get(self.url, {"min_capacity": 500, "max_capacity": 3000})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_opening_year_filters__returns_filtered_rides(self):
"""Test filtering by opening year."""
response = self.client.get(self.url, {
'min_opening_year': 2000,
'max_opening_year': 2024
})
response = self.client.get(self.url, {"min_opening_year": 2000, "max_opening_year": 2024})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_ordering__returns_ordered_results(self):
"""Test ordering functionality."""
response = self.client.get(self.url, {'ordering': '-name'})
response = self.client.get(self.url, {"ordering": "-name"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_multiple_filters__returns_combined_results(self):
"""Test combining multiple filters."""
response = self.client.get(self.url, {
'category': 'RC',
'status': 'OPERATING',
'ordering': 'name'
})
response = self.client.get(self.url, {"category": "RC", "status": "OPERATING", "ordering": "name"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__pagination__page_size_respected(self):
"""Test that page_size parameter is respected."""
response = self.client.get(self.url, {'page_size': 1})
response = self.client.get(self.url, {"page_size": 1})
self.assertEqual(response.status_code, status.HTTP_200_OK)
results = response.data.get('results', [])
results = response.data.get("results", [])
self.assertLessEqual(len(results), 1)
@@ -203,14 +184,14 @@ class TestRideCreateAPIView(EnhancedAPITestCase):
self.staff_user = StaffUserFactory()
self.park = ParkFactory()
self.manufacturer = ManufacturerCompanyFactory()
self.url = '/api/v1/rides/'
self.url = "/api/v1/rides/"
self.valid_ride_data = {
'name': 'New Test Ride',
'description': 'A test ride for API testing',
'park_id': self.park.id,
'category': 'RC',
'status': 'OPERATING',
"name": "New Test Ride",
"description": "A test ride for API testing",
"park_id": self.park.id,
"category": "RC",
"status": "OPERATING",
}
def test__ride_create__unauthenticated__returns_401(self):
@@ -219,11 +200,9 @@ class TestRideCreateAPIView(EnhancedAPITestCase):
# Based on the view, AllowAny is used, so it might allow creation
# If not, it should be 401
self.assertIn(response.status_code, [
status.HTTP_201_CREATED,
status.HTTP_401_UNAUTHORIZED,
status.HTTP_400_BAD_REQUEST
])
self.assertIn(
response.status_code, [status.HTTP_201_CREATED, status.HTTP_401_UNAUTHORIZED, status.HTTP_400_BAD_REQUEST]
)
def test__ride_create__with_valid_data__creates_ride(self):
"""Test creating ride with valid data."""
@@ -231,39 +210,36 @@ class TestRideCreateAPIView(EnhancedAPITestCase):
response = self.client.post(self.url, self.valid_ride_data)
# Should create or return validation error if models not available
self.assertIn(response.status_code, [
status.HTTP_201_CREATED,
status.HTTP_400_BAD_REQUEST,
status.HTTP_501_NOT_IMPLEMENTED
])
self.assertIn(
response.status_code,
[status.HTTP_201_CREATED, status.HTTP_400_BAD_REQUEST, status.HTTP_501_NOT_IMPLEMENTED],
)
def test__ride_create__with_invalid_park__returns_error(self):
"""Test creating ride with invalid park ID."""
self.client.force_authenticate(user=self.user)
invalid_data = self.valid_ride_data.copy()
invalid_data['park_id'] = 99999
invalid_data["park_id"] = 99999
response = self.client.post(self.url, invalid_data)
self.assertIn(response.status_code, [
status.HTTP_400_BAD_REQUEST,
status.HTTP_404_NOT_FOUND,
status.HTTP_501_NOT_IMPLEMENTED
])
self.assertIn(
response.status_code,
[status.HTTP_400_BAD_REQUEST, status.HTTP_404_NOT_FOUND, status.HTTP_501_NOT_IMPLEMENTED],
)
def test__ride_create__with_manufacturer__creates_ride_with_relationship(self):
"""Test creating ride with manufacturer relationship."""
self.client.force_authenticate(user=self.user)
data_with_manufacturer = self.valid_ride_data.copy()
data_with_manufacturer['manufacturer_id'] = self.manufacturer.id
data_with_manufacturer["manufacturer_id"] = self.manufacturer.id
response = self.client.post(self.url, data_with_manufacturer)
self.assertIn(response.status_code, [
status.HTTP_201_CREATED,
status.HTTP_400_BAD_REQUEST,
status.HTTP_501_NOT_IMPLEMENTED
])
self.assertIn(
response.status_code,
[status.HTTP_201_CREATED, status.HTTP_400_BAD_REQUEST, status.HTTP_501_NOT_IMPLEMENTED],
)
class TestRideDetailAPIView(EnhancedAPITestCase):
@@ -275,7 +251,7 @@ class TestRideDetailAPIView(EnhancedAPITestCase):
self.user = UserFactory()
self.park = ParkFactory()
self.ride = RideFactory(park=self.park)
self.url = f'/api/v1/rides/{self.ride.id}/'
self.url = f"/api/v1/rides/{self.ride.id}/"
def test__ride_detail__unauthenticated__can_access(self):
"""Test that unauthenticated users can access ride detail."""
@@ -289,13 +265,13 @@ class TestRideDetailAPIView(EnhancedAPITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
expected_fields = ['id', 'name', 'description', 'category', 'status', 'park']
expected_fields = ["id", "name", "description", "category", "status", "park"]
for field in expected_fields:
self.assertIn(field, response.data)
def test__ride_detail__invalid_id__returns_404(self):
"""Test that invalid ride ID returns 404."""
response = self.client.get('/api/v1/rides/99999/')
response = self.client.get("/api/v1/rides/99999/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@@ -309,34 +285,30 @@ class TestRideUpdateAPIView(EnhancedAPITestCase):
self.user = UserFactory()
self.park = ParkFactory()
self.ride = RideFactory(park=self.park)
self.url = f'/api/v1/rides/{self.ride.id}/'
self.url = f"/api/v1/rides/{self.ride.id}/"
def test__ride_update__partial_update__updates_field(self):
"""Test partial update (PATCH)."""
self.client.force_authenticate(user=self.user)
update_data = {'description': 'Updated description'}
update_data = {"description": "Updated description"}
response = self.client.patch(self.url, update_data)
self.assertIn(response.status_code, [
status.HTTP_200_OK,
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN
])
self.assertIn(
response.status_code, [status.HTTP_200_OK, status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]
)
def test__ride_update__move_to_new_park__updates_relationship(self):
"""Test moving ride to a different park."""
self.client.force_authenticate(user=self.user)
new_park = ParkFactory()
update_data = {'park_id': new_park.id}
update_data = {"park_id": new_park.id}
response = self.client.patch(self.url, update_data)
self.assertIn(response.status_code, [
status.HTTP_200_OK,
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN
])
self.assertIn(
response.status_code, [status.HTTP_200_OK, status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]
)
class TestRideDeleteAPIView(EnhancedAPITestCase):
@@ -348,18 +320,16 @@ class TestRideDeleteAPIView(EnhancedAPITestCase):
self.user = UserFactory()
self.park = ParkFactory()
self.ride = RideFactory(park=self.park)
self.url = f'/api/v1/rides/{self.ride.id}/'
self.url = f"/api/v1/rides/{self.ride.id}/"
def test__ride_delete__authenticated__deletes_ride(self):
"""Test deleting a ride."""
self.client.force_authenticate(user=self.user)
response = self.client.delete(self.url)
self.assertIn(response.status_code, [
status.HTTP_204_NO_CONTENT,
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN
])
self.assertIn(
response.status_code, [status.HTTP_204_NO_CONTENT, status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN]
)
class TestFilterOptionsAPIView(EnhancedAPITestCase):
@@ -368,7 +338,7 @@ class TestFilterOptionsAPIView(EnhancedAPITestCase):
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.url = '/api/v1/rides/filter-options/'
self.url = "/api/v1/rides/filter-options/"
def test__filter_options__returns_all_options(self):
"""Test that filter options endpoint returns all filter options."""
@@ -377,7 +347,7 @@ class TestFilterOptionsAPIView(EnhancedAPITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Check for expected filter categories
expected_keys = ['categories', 'statuses']
expected_keys = ["categories", "statuses"]
for key in expected_keys:
self.assertIn(key, response.data)
@@ -386,14 +356,14 @@ class TestFilterOptionsAPIView(EnhancedAPITestCase):
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('ranges', response.data)
self.assertIn("ranges", response.data)
def test__filter_options__includes_ordering_options(self):
"""Test that filter options include ordering options."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('ordering_options', response.data)
self.assertIn("ordering_options", response.data)
class TestHybridRideAPIView(EnhancedAPITestCase):
@@ -405,92 +375,86 @@ class TestHybridRideAPIView(EnhancedAPITestCase):
self.park = ParkFactory()
self.manufacturer = ManufacturerCompanyFactory()
self.rides = [
RideFactory(park=self.park, manufacturer=self.manufacturer, status='OPERATING', category='RC'),
RideFactory(park=self.park, status='OPERATING', category='DR'),
RideFactory(park=self.park, status='CLOSED_TEMP', category='RC'),
RideFactory(park=self.park, manufacturer=self.manufacturer, status="OPERATING", category="RC"),
RideFactory(park=self.park, status="OPERATING", category="DR"),
RideFactory(park=self.park, status="CLOSED_TEMP", category="RC"),
]
self.url = '/api/v1/rides/hybrid/'
self.url = "/api/v1/rides/hybrid/"
def test__hybrid_ride__initial_load__returns_rides(self):
"""Test initial load returns rides with metadata."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data.get('success', False))
self.assertIn('data', response.data)
self.assertIn('rides', response.data['data'])
self.assertIn('total_count', response.data['data'])
self.assertTrue(response.data.get("success", False))
self.assertIn("data", response.data)
self.assertIn("rides", response.data["data"])
self.assertIn("total_count", response.data["data"])
def test__hybrid_ride__with_category_filter__returns_filtered_rides(self):
"""Test filtering by category."""
response = self.client.get(self.url, {'category': 'RC'})
response = self.client.get(self.url, {"category": "RC"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_status_filter__returns_filtered_rides(self):
"""Test filtering by status."""
response = self.client.get(self.url, {'status': 'OPERATING'})
response = self.client.get(self.url, {"status": "OPERATING"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_park_slug__returns_filtered_rides(self):
"""Test filtering by park slug."""
response = self.client.get(self.url, {'park_slug': self.park.slug})
response = self.client.get(self.url, {"park_slug": self.park.slug})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_manufacturer_filter__returns_filtered_rides(self):
"""Test filtering by manufacturer."""
response = self.client.get(self.url, {'manufacturer': self.manufacturer.slug})
response = self.client.get(self.url, {"manufacturer": self.manufacturer.slug})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_offset__returns_progressive_data(self):
"""Test progressive loading with offset."""
response = self.client.get(self.url, {'offset': 0})
response = self.client.get(self.url, {"offset": 0})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('has_more', response.data['data'])
self.assertIn("has_more", response.data["data"])
def test__hybrid_ride__with_invalid_offset__returns_400(self):
"""Test invalid offset parameter."""
response = self.client.get(self.url, {'offset': 'invalid'})
response = self.client.get(self.url, {"offset": "invalid"})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test__hybrid_ride__with_search__returns_matching_rides(self):
"""Test search functionality."""
response = self.client.get(self.url, {'search': 'test'})
response = self.client.get(self.url, {"search": "test"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_rating_filters__returns_filtered_rides(self):
"""Test filtering by rating range."""
response = self.client.get(self.url, {'rating_min': 5.0, 'rating_max': 10.0})
response = self.client.get(self.url, {"rating_min": 5.0, "rating_max": 10.0})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_height_filters__returns_filtered_rides(self):
"""Test filtering by height requirement range."""
response = self.client.get(self.url, {
'height_requirement_min': 36,
'height_requirement_max': 54
})
response = self.client.get(self.url, {"height_requirement_min": 36, "height_requirement_max": 54})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_roller_coaster_filters__returns_filtered_rides(self):
"""Test filtering by roller coaster specific fields."""
response = self.client.get(self.url, {
'roller_coaster_type': 'SITDOWN',
'track_material': 'STEEL'
})
response = self.client.get(self.url, {"roller_coaster_type": "SITDOWN", "track_material": "STEEL"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__hybrid_ride__with_inversions_filter__returns_filtered_rides(self):
"""Test filtering by inversions."""
response = self.client.get(self.url, {'has_inversions': 'true'})
response = self.client.get(self.url, {"has_inversions": "true"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -501,19 +465,19 @@ class TestRideFilterMetadataAPIView(EnhancedAPITestCase):
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.url = '/api/v1/rides/filter-metadata/'
self.url = "/api/v1/rides/filter-metadata/"
def test__filter_metadata__unscoped__returns_all_metadata(self):
"""Test getting unscoped filter metadata."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data.get('success', False))
self.assertIn('data', response.data)
self.assertTrue(response.data.get("success", False))
self.assertIn("data", response.data)
def test__filter_metadata__scoped__returns_filtered_metadata(self):
"""Test getting scoped filter metadata."""
response = self.client.get(self.url, {'scoped': 'true', 'category': 'RC'})
response = self.client.get(self.url, {"scoped": "true", "category": "RC"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -524,19 +488,19 @@ class TestCompanySearchAPIView(EnhancedAPITestCase):
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.manufacturer = ManufacturerCompanyFactory(name='Bolliger & Mabillard')
self.url = '/api/v1/rides/search/companies/'
self.manufacturer = ManufacturerCompanyFactory(name="Bolliger & Mabillard")
self.url = "/api/v1/rides/search/companies/"
def test__company_search__with_query__returns_matching_companies(self):
"""Test searching for companies."""
response = self.client.get(self.url, {'q': 'Bolliger'})
response = self.client.get(self.url, {"q": "Bolliger"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsInstance(response.data, list)
def test__company_search__empty_query__returns_empty_list(self):
"""Test empty query returns empty list."""
response = self.client.get(self.url, {'q': ''})
response = self.client.get(self.url, {"q": ""})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, [])
@@ -555,19 +519,19 @@ class TestRideModelSearchAPIView(EnhancedAPITestCase):
def setUp(self):
"""Set up test data."""
self.client = APIClient()
self.ride_model = RideModelFactory(name='Hyper Coaster')
self.url = '/api/v1/rides/search-ride-models/'
self.ride_model = RideModelFactory(name="Hyper Coaster")
self.url = "/api/v1/rides/search-ride-models/"
def test__ride_model_search__with_query__returns_matching_models(self):
"""Test searching for ride models."""
response = self.client.get(self.url, {'q': 'Hyper'})
response = self.client.get(self.url, {"q": "Hyper"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsInstance(response.data, list)
def test__ride_model_search__empty_query__returns_empty_list(self):
"""Test empty query returns empty list."""
response = self.client.get(self.url, {'q': ''})
response = self.client.get(self.url, {"q": ""})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, [])
@@ -580,19 +544,19 @@ class TestRideSearchSuggestionsAPIView(EnhancedAPITestCase):
"""Set up test data."""
self.client = APIClient()
self.park = ParkFactory()
self.ride = RideFactory(park=self.park, name='Superman: Escape from Krypton')
self.url = '/api/v1/rides/search-suggestions/'
self.ride = RideFactory(park=self.park, name="Superman: Escape from Krypton")
self.url = "/api/v1/rides/search-suggestions/"
def test__search_suggestions__with_query__returns_suggestions(self):
"""Test getting search suggestions."""
response = self.client.get(self.url, {'q': 'Superman'})
response = self.client.get(self.url, {"q": "Superman"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIsInstance(response.data, list)
def test__search_suggestions__empty_query__returns_empty_list(self):
"""Test empty query returns empty list."""
response = self.client.get(self.url, {'q': ''})
response = self.client.get(self.url, {"q": ""})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, [])
@@ -607,7 +571,7 @@ class TestRideImageSettingsAPIView(EnhancedAPITestCase):
self.user = UserFactory()
self.park = ParkFactory()
self.ride = RideFactory(park=self.park)
self.url = f'/api/v1/rides/{self.ride.id}/image-settings/'
self.url = f"/api/v1/rides/{self.ride.id}/image-settings/"
def test__image_settings__patch__updates_settings(self):
"""Test updating ride image settings."""
@@ -616,17 +580,15 @@ class TestRideImageSettingsAPIView(EnhancedAPITestCase):
response = self.client.patch(self.url, {})
# Should handle the request
self.assertIn(response.status_code, [
status.HTTP_200_OK,
status.HTTP_400_BAD_REQUEST,
status.HTTP_401_UNAUTHORIZED
])
self.assertIn(
response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED]
)
def test__image_settings__invalid_ride__returns_404(self):
"""Test updating image settings for non-existent ride."""
self.client.force_authenticate(user=self.user)
response = self.client.patch('/api/v1/rides/99999/image-settings/', {})
response = self.client.patch("/api/v1/rides/99999/image-settings/", {})
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
@@ -639,55 +601,55 @@ class TestRideAPIRollerCoasterFilters(EnhancedAPITestCase):
self.client = APIClient()
self.park = ParkFactory()
# Create coasters with different stats
self.coaster1 = CoasterFactory(park=self.park, name='Steel Vengeance')
self.coaster2 = CoasterFactory(park=self.park, name='Millennium Force')
self.url = '/api/v1/rides/'
self.coaster1 = CoasterFactory(park=self.park, name="Steel Vengeance")
self.coaster2 = CoasterFactory(park=self.park, name="Millennium Force")
self.url = "/api/v1/rides/"
def test__ride_list__with_roller_coaster_type__filters_correctly(self):
"""Test filtering by roller coaster type."""
response = self.client.get(self.url, {'roller_coaster_type': 'SITDOWN'})
response = self.client.get(self.url, {"roller_coaster_type": "SITDOWN"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_track_material__filters_correctly(self):
"""Test filtering by track material."""
response = self.client.get(self.url, {'track_material': 'STEEL'})
response = self.client.get(self.url, {"track_material": "STEEL"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_propulsion_system__filters_correctly(self):
"""Test filtering by propulsion system."""
response = self.client.get(self.url, {'propulsion_system': 'CHAIN'})
response = self.client.get(self.url, {"propulsion_system": "CHAIN"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_height_ft_range__filters_correctly(self):
"""Test filtering by height in feet."""
response = self.client.get(self.url, {'min_height_ft': 100, 'max_height_ft': 500})
response = self.client.get(self.url, {"min_height_ft": 100, "max_height_ft": 500})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_speed_mph_range__filters_correctly(self):
"""Test filtering by speed in mph."""
response = self.client.get(self.url, {'min_speed_mph': 50, 'max_speed_mph': 150})
response = self.client.get(self.url, {"min_speed_mph": 50, "max_speed_mph": 150})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__with_inversions_range__filters_correctly(self):
"""Test filtering by number of inversions."""
response = self.client.get(self.url, {'min_inversions': 0, 'max_inversions': 14})
response = self.client.get(self.url, {"min_inversions": 0, "max_inversions": 14})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__ordering_by_height__orders_correctly(self):
"""Test ordering by height."""
response = self.client.get(self.url, {'ordering': '-height_ft'})
response = self.client.get(self.url, {"ordering": "-height_ft"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test__ride_list__ordering_by_speed__orders_correctly(self):
"""Test ordering by speed."""
response = self.client.get(self.url, {'ordering': '-speed_mph'})
response = self.client.get(self.url, {"ordering": "-speed_mph"})
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -702,7 +664,7 @@ class TestRideAPIEdgeCases(EnhancedAPITestCase):
def test__ride_list__empty_database__returns_empty_list(self):
"""Test API behavior with no rides in database."""
# This depends on existing data, just verify no error
response = self.client.get('/api/v1/rides/')
response = self.client.get("/api/v1/rides/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -716,20 +678,20 @@ class TestRideAPIEdgeCases(EnhancedAPITestCase):
]
for search_term in special_searches:
response = self.client.get('/api/v1/rides/', {'search': search_term})
response = self.client.get("/api/v1/rides/", {"search": search_term})
# Should not crash
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test__ride_list__extreme_pagination__handled_safely(self):
"""Test extreme pagination values."""
response = self.client.get('/api/v1/rides/', {'page': 99999, 'page_size': 1000})
response = self.client.get("/api/v1/rides/", {"page": 99999, "page_size": 1000})
# Should handle gracefully
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND])
def test__ride_list__invalid_ordering__handled_safely(self):
"""Test invalid ordering parameter."""
response = self.client.get('/api/v1/rides/', {'ordering': 'invalid_field'})
response = self.client.get("/api/v1/rides/", {"ordering": "invalid_field"})
# Should use default ordering
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -749,7 +711,7 @@ class TestRideAPIQueryOptimization(EnhancedAPITestCase):
for _i in range(5):
RideFactory(park=self.park)
response = self.client.get('/api/v1/rides/')
response = self.client.get("/api/v1/rides/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -757,8 +719,8 @@ class TestRideAPIQueryOptimization(EnhancedAPITestCase):
"""Test that ride list handles larger datasets efficiently."""
# Create batch of rides
for i in range(10):
RideFactory(park=self.park, name=f'Ride {i}')
RideFactory(park=self.park, name=f"Ride {i}")
response = self.client.get('/api/v1/rides/')
response = self.client.get("/api/v1/rides/")
self.assertEqual(response.status_code, status.HTTP_200_OK)

View File

@@ -260,11 +260,7 @@ def clear_cache():
def pytest_configure(config):
"""Register custom pytest markers."""
config.addinivalue_line("markers", "unit: Unit tests (fast, isolated)")
config.addinivalue_line(
"markers", "integration: Integration tests (may use database)"
)
config.addinivalue_line(
"markers", "e2e: End-to-end browser tests (slow, requires server)"
)
config.addinivalue_line("markers", "integration: Integration tests (may use database)")
config.addinivalue_line("markers", "e2e: End-to-end browser tests (slow, requires server)")
config.addinivalue_line("markers", "slow: Tests that take a long time to run")
config.addinivalue_line("markers", "api: API endpoint tests")

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

View File

@@ -202,9 +202,7 @@ class RideFactory(DjangoModelFactory):
manufacturer = factory.SubFactory(ManufacturerCompanyFactory)
designer = factory.SubFactory(DesignerCompanyFactory)
ride_model = factory.SubFactory(RideModelFactory)
park_area = factory.SubFactory(
ParkAreaFactory, park=factory.SelfAttribute("..park")
)
park_area = factory.SubFactory(ParkAreaFactory, park=factory.SelfAttribute("..park"))
@factory.post_generation
def create_location(obj, create, extracted, **kwargs):
@@ -297,9 +295,7 @@ class Traits:
"""Trait for closed parks."""
return {
"status": "CLOSED_PERM",
"closing_date": factory.Faker(
"date_between", start_date="-10y", end_date="today"
),
"closing_date": factory.Faker("date_between", start_date="-10y", end_date="today"),
}
@staticmethod
@@ -310,11 +306,7 @@ class Traits:
@staticmethod
def recent_submission():
"""Trait for recent submissions."""
return {
"submitted_at": factory.Faker(
"date_time_between", start_date="-7d", end_date="now"
)
}
return {"submitted_at": factory.Faker("date_time_between", start_date="-7d", end_date="now")}
# Specialized factories for testing scenarios
@@ -378,11 +370,13 @@ class CloudflareImageFactory(DjangoModelFactory):
@factory.lazy_attribute
def expires_at(self):
from django.utils import timezone
return timezone.now() + timezone.timedelta(days=365)
@factory.lazy_attribute
def uploaded_at(self):
from django.utils import timezone
return timezone.now()

View File

@@ -4,7 +4,6 @@ Tests for Park forms.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from django.test import TestCase

View File

@@ -4,7 +4,6 @@ Tests for Ride forms.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from django.test import TestCase

View File

@@ -28,27 +28,16 @@ class TestFSMTransitionViewHTMX(TestCase):
def setUpTestData(cls):
"""Set up test data for all tests in this class."""
# Create regular user
cls.user = User.objects.create_user(
username="testuser",
email="testuser@example.com",
password="testpass123"
)
cls.user = User.objects.create_user(username="testuser", email="testuser@example.com", password="testpass123")
# Create moderator user
cls.moderator = User.objects.create_user(
username="moderator",
email="moderator@example.com",
password="modpass123",
is_staff=True
username="moderator", email="moderator@example.com", password="modpass123", is_staff=True
)
# Create admin user
cls.admin = User.objects.create_user(
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
)
def setUp(self):
@@ -76,7 +65,7 @@ class TestFSMTransitionViewHTMX(TestCase):
submission_type="EDIT",
changes={"description": "Test change"},
reason="Integration test",
status="PENDING"
status="PENDING",
)
url = reverse(
@@ -85,15 +74,12 @@ class TestFSMTransitionViewHTMX(TestCase):
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
"transition_name": "transition_to_approved",
},
)
# Make request with HTMX header
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
# Should return 200 OK
self.assertEqual(response.status_code, 200)
@@ -129,7 +115,7 @@ class TestFSMTransitionViewHTMX(TestCase):
submission_type="EDIT",
changes={"description": "Test change non-htmx"},
reason="Integration test non-htmx",
status="PENDING"
status="PENDING",
)
url = reverse(
@@ -138,8 +124,8 @@ class TestFSMTransitionViewHTMX(TestCase):
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
"transition_name": "transition_to_approved",
},
)
# Make request WITHOUT HTMX header
@@ -177,7 +163,7 @@ class TestFSMTransitionViewHTMX(TestCase):
submission_type="EDIT",
changes={"description": "Test partial"},
reason="Partial test",
status="PENDING"
status="PENDING",
)
url = reverse(
@@ -186,14 +172,11 @@ class TestFSMTransitionViewHTMX(TestCase):
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
"transition_name": "transition_to_approved",
},
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
# Response should contain HTML (partial template)
self.assertIn("text/html", response["Content-Type"])
@@ -217,14 +200,11 @@ class TestFSMTransitionViewHTMX(TestCase):
"app_label": "parks",
"model_name": "park",
"pk": park.pk,
"transition_name": "transition_to_closed_temp"
}
"transition_name": "transition_to_closed_temp",
},
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
# Parse HX-Trigger header
trigger_data = json.loads(response["HX-Trigger"])
@@ -249,14 +229,11 @@ class TestFSMTransitionViewHTMX(TestCase):
"app_label": "nonexistent",
"model_name": "fakemodel",
"pk": 1,
"transition_name": "fake_transition"
}
"transition_name": "fake_transition",
},
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
# Should return 404
self.assertEqual(response.status_code, 404)
@@ -282,14 +259,11 @@ class TestFSMTransitionViewHTMX(TestCase):
"app_label": "parks",
"model_name": "park",
"pk": park.pk,
"transition_name": "nonexistent_transition"
}
"transition_name": "nonexistent_transition",
},
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
# Should return 400 Bad Request
self.assertEqual(response.status_code, 400)
@@ -320,7 +294,7 @@ class TestFSMTransitionViewHTMX(TestCase):
submission_type="EDIT",
changes={"description": "Permission test"},
reason="Permission test",
status="PENDING"
status="PENDING",
)
url = reverse(
@@ -329,14 +303,11 @@ class TestFSMTransitionViewHTMX(TestCase):
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
"transition_name": "transition_to_approved",
},
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
# Should return 400 or 403 (permission denied)
self.assertIn(response.status_code, [400, 403])
@@ -355,10 +326,7 @@ class TestFSMTransitionViewParkModel(TestCase):
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
username="mod_park",
email="mod_park@example.com",
password="modpass123",
is_staff=True
username="mod_park", email="mod_park@example.com", password="modpass123", is_staff=True
)
def setUp(self):
@@ -379,14 +347,11 @@ class TestFSMTransitionViewParkModel(TestCase):
"app_label": "parks",
"model_name": "park",
"pk": park.pk,
"transition_name": "transition_to_closed_temp"
}
"transition_name": "transition_to_closed_temp",
},
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200)
@@ -416,14 +381,11 @@ class TestFSMTransitionViewParkModel(TestCase):
"app_label": "parks",
"model_name": "park",
"pk": park.pk,
"transition_name": "transition_to_operating"
}
"transition_name": "transition_to_operating",
},
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200)
@@ -445,14 +407,11 @@ class TestFSMTransitionViewParkModel(TestCase):
"app_label": "parks",
"model_name": "park",
"slug": park.slug,
"transition_name": "transition_to_closed_temp"
}
"transition_name": "transition_to_closed_temp",
},
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200)
@@ -471,10 +430,7 @@ class TestFSMTransitionViewRideModel(TestCase):
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
username="mod_ride",
email="mod_ride@example.com",
password="modpass123",
is_staff=True
username="mod_ride", email="mod_ride@example.com", password="modpass123", is_staff=True
)
def setUp(self):
@@ -495,14 +451,11 @@ class TestFSMTransitionViewRideModel(TestCase):
"app_label": "rides",
"model_name": "ride",
"pk": ride.pk,
"transition_name": "transition_to_closed_temp"
}
"transition_name": "transition_to_closed_temp",
},
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200)
@@ -524,18 +477,10 @@ class TestFSMTransitionViewRideModel(TestCase):
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "rides",
"model_name": "ride",
"pk": ride.pk,
"transition_name": "transition_to_sbno"
}
kwargs={"app_label": "rides", "model_name": "ride", "pk": ride.pk, "transition_name": "transition_to_sbno"},
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200)
@@ -553,17 +498,10 @@ class TestFSMTransitionViewModerationModels(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
username="submitter",
email="submitter@example.com",
password="testpass123"
)
cls.user = User.objects.create_user(username="submitter", email="submitter@example.com", password="testpass123")
cls.moderator = User.objects.create_user(
username="mod_moderation",
email="mod_moderation@example.com",
password="modpass123",
is_staff=True
username="mod_moderation", email="mod_moderation@example.com", password="modpass123", is_staff=True
)
def setUp(self):
@@ -588,7 +526,7 @@ class TestFSMTransitionViewModerationModels(TestCase):
submission_type="EDIT",
changes={"description": "Approve test"},
reason="Approve test",
status="PENDING"
status="PENDING",
)
url = reverse(
@@ -597,14 +535,11 @@ class TestFSMTransitionViewModerationModels(TestCase):
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
"transition_name": "transition_to_approved",
},
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200)
@@ -633,7 +568,7 @@ class TestFSMTransitionViewModerationModels(TestCase):
submission_type="EDIT",
changes={"description": "Reject test"},
reason="Reject test",
status="PENDING"
status="PENDING",
)
url = reverse(
@@ -642,14 +577,11 @@ class TestFSMTransitionViewModerationModels(TestCase):
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_rejected"
}
"transition_name": "transition_to_rejected",
},
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200)
@@ -678,7 +610,7 @@ class TestFSMTransitionViewModerationModels(TestCase):
submission_type="EDIT",
changes={"description": "Escalate test"},
reason="Escalate test",
status="PENDING"
status="PENDING",
)
url = reverse(
@@ -687,14 +619,11 @@ class TestFSMTransitionViewModerationModels(TestCase):
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_escalated"
}
"transition_name": "transition_to_escalated",
},
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
response = self.client.post(url, HTTP_HX_REQUEST="true")
self.assertEqual(response.status_code, 200)
@@ -712,16 +641,11 @@ class TestFSMTransitionViewStateLog(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
username="submitter_log",
email="submitter_log@example.com",
password="testpass123"
username="submitter_log", email="submitter_log@example.com", password="testpass123"
)
cls.moderator = User.objects.create_user(
username="mod_log",
email="mod_log@example.com",
password="modpass123",
is_staff=True
username="mod_log", email="mod_log@example.com", password="modpass123", is_staff=True
)
def setUp(self):
@@ -748,13 +672,12 @@ class TestFSMTransitionViewStateLog(TestCase):
submission_type="EDIT",
changes={"description": "StateLog test"},
reason="StateLog test",
status="PENDING"
status="PENDING",
)
# Count existing StateLog entries
initial_log_count = StateLog.objects.filter(
content_type=ContentType.objects.get_for_model(EditSubmission),
object_id=submission.pk
content_type=ContentType.objects.get_for_model(EditSubmission), object_id=submission.pk
).count()
url = reverse(
@@ -763,28 +686,23 @@ class TestFSMTransitionViewStateLog(TestCase):
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
"transition_name": "transition_to_approved",
},
)
self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.client.post(url, HTTP_HX_REQUEST="true")
# Check that a new StateLog entry was created
new_log_count = StateLog.objects.filter(
content_type=ContentType.objects.get_for_model(EditSubmission),
object_id=submission.pk
content_type=ContentType.objects.get_for_model(EditSubmission), object_id=submission.pk
).count()
self.assertEqual(new_log_count, initial_log_count + 1)
# Verify the StateLog entry details
latest_log = StateLog.objects.filter(
content_type=ContentType.objects.get_for_model(EditSubmission),
object_id=submission.pk
).latest('timestamp')
content_type=ContentType.objects.get_for_model(EditSubmission), object_id=submission.pk
).latest("timestamp")
self.assertEqual(latest_log.state, "APPROVED")
self.assertEqual(latest_log.by, self.moderator)

View File

@@ -9,6 +9,7 @@ from datetime import date, timedelta
import pytest
from django.test import TestCase
from django_fsm import TransitionNotAllowed
from tests.factories import (
ParkAreaFactory,
@@ -55,7 +56,7 @@ class TestParkFSMTransitions(TestCase):
user = UserFactory()
# This should fail - can't reopen permanently closed park
with pytest.raises(Exception):
with pytest.raises((TransitionNotAllowed, ValueError)):
park.open(user=user)

View File

@@ -138,9 +138,7 @@ class TestParkReviewWorkflow(TestCase):
ParkReviewFactory(park=park, user=user2, rating=10, is_published=True)
# Calculate average
avg = park.reviews.filter(is_published=True).values_list(
"rating", flat=True
)
avg = park.reviews.filter(is_published=True).values_list("rating", flat=True)
calculated_avg = sum(avg) / len(avg)
assert calculated_avg == 9.0

View File

@@ -31,9 +31,7 @@ class TestParkPhotoUploadWorkflow(TestCase):
@patch("apps.parks.services.media_service.MediaService.process_image")
@patch("apps.parks.services.media_service.MediaService.generate_default_caption")
@patch("apps.parks.services.media_service.MediaService.extract_exif_date")
def test__upload_photo__creates_pending_photo(
self, mock_exif, mock_caption, mock_process, mock_validate
):
def test__upload_photo__creates_pending_photo(self, mock_exif, mock_caption, mock_process, mock_validate):
"""Test uploading photo creates a pending photo."""
mock_validate.return_value = (True, None)
mock_process.return_value = Mock()

View File

@@ -4,7 +4,6 @@ Tests for Core managers and querysets.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from django.test import TestCase
@@ -23,6 +22,7 @@ class TestBaseQuerySet(TestCase):
"""Test active filters by is_active field if present."""
# Using User model which has is_active
from django.contrib.auth import get_user_model
User = get_user_model()
active_user = User.objects.create_user(
@@ -43,6 +43,7 @@ class TestBaseQuerySet(TestCase):
# Created just now, should be in recent
from apps.parks.models import Park
result = Park.objects.recent(days=30)
assert park in result
@@ -53,6 +54,7 @@ class TestBaseQuerySet(TestCase):
park2 = ParkFactory(name="Kings Island")
from apps.parks.models import Park
result = Park.objects.search(query="Cedar")
assert park1 in result
@@ -64,6 +66,7 @@ class TestBaseQuerySet(TestCase):
park2 = ParkFactory()
from apps.parks.models import Park
result = Park.objects.search(query="")
assert park1 in result
@@ -81,6 +84,7 @@ class TestLocationQuerySet(TestCase):
# Location is created by factory post_generation
from apps.parks.models import Park
# This tests the pattern - actual filtering depends on location setup
result = Park.objects.all()
@@ -259,11 +263,10 @@ class TestBaseManager(TestCase):
def test__active__delegates_to_queryset(self):
"""Test active method delegates to queryset."""
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.create_user(
username="test", email="test@test.com", password="test", is_active=True
)
user = User.objects.create_user(username="test", email="test@test.com", password="test", is_active=True)
# BaseManager's active method should work
result = User.objects.filter(is_active=True)

View File

@@ -4,7 +4,6 @@ Tests for Park managers and querysets.
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
"""
import pytest
from django.test import TestCase

View File

@@ -93,9 +93,7 @@ class TestContractValidationMiddlewareFilterValidation(TestCase):
self.middleware.enabled = True
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_filter_metadata__valid_categorical_filters__no_violation(
self, mock_log
):
def test__validate_filter_metadata__valid_categorical_filters__no_violation(self, mock_log):
"""Test valid categorical filter format doesn't log violation."""
request = self.factory.get("/api/v1/parks/filter-options/")
valid_data = {
@@ -118,11 +116,7 @@ class TestContractValidationMiddlewareFilterValidation(TestCase):
def test__validate_filter_metadata__string_options__logs_violation(self, mock_log):
"""Test string filter options logs contract violation."""
request = self.factory.get("/api/v1/parks/filter-options/")
invalid_data = {
"categorical": {
"status": ["OPERATING", "CLOSED"] # Strings instead of objects
}
}
invalid_data = {"categorical": {"status": ["OPERATING", "CLOSED"]}} # Strings instead of objects
response = JsonResponse(invalid_data)
self.middleware.process_response(request, response)
@@ -133,18 +127,10 @@ class TestContractValidationMiddlewareFilterValidation(TestCase):
assert any("CATEGORICAL_OPTION_IS_STRING" in arg for arg in call_args)
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_filter_metadata__missing_value_property__logs_violation(
self, mock_log
):
def test__validate_filter_metadata__missing_value_property__logs_violation(self, mock_log):
"""Test filter option missing 'value' property logs violation."""
request = self.factory.get("/api/v1/parks/filter-options/")
invalid_data = {
"categorical": {
"status": [
{"label": "Operating", "count": 10} # Missing 'value'
]
}
}
invalid_data = {"categorical": {"status": [{"label": "Operating", "count": 10}]}} # Missing 'value'
response = JsonResponse(invalid_data)
self.middleware.process_response(request, response)
@@ -154,18 +140,10 @@ class TestContractValidationMiddlewareFilterValidation(TestCase):
assert any("MISSING_VALUE_PROPERTY" in arg for arg in call_args)
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_filter_metadata__missing_label_property__logs_violation(
self, mock_log
):
def test__validate_filter_metadata__missing_label_property__logs_violation(self, mock_log):
"""Test filter option missing 'label' property logs violation."""
request = self.factory.get("/api/v1/parks/filter-options/")
invalid_data = {
"categorical": {
"status": [
{"value": "OPERATING", "count": 10} # Missing 'label'
]
}
}
invalid_data = {"categorical": {"status": [{"value": "OPERATING", "count": 10}]}} # Missing 'label'
response = JsonResponse(invalid_data)
self.middleware.process_response(request, response)
@@ -188,11 +166,7 @@ class TestContractValidationMiddlewareRangeValidation(TestCase):
def test__validate_range_filter__valid_range__no_violation(self, mock_log):
"""Test valid range filter format doesn't log violation."""
request = self.factory.get("/api/v1/rides/filter-options/")
valid_data = {
"ranges": {
"height": {"min": 0, "max": 500, "step": 10, "unit": "ft"}
}
}
valid_data = {"ranges": {"height": {"min": 0, "max": 500, "step": 10, "unit": "ft"}}}
response = JsonResponse(valid_data)
self.middleware.process_response(request, response)
@@ -205,11 +179,7 @@ class TestContractValidationMiddlewareRangeValidation(TestCase):
def test__validate_range_filter__missing_min_max__logs_violation(self, mock_log):
"""Test range filter missing min/max logs violation."""
request = self.factory.get("/api/v1/rides/filter-options/")
invalid_data = {
"ranges": {
"height": {"step": 10} # Missing 'min' and 'max'
}
}
invalid_data = {"ranges": {"height": {"step": 10}}} # Missing 'min' and 'max'
response = JsonResponse(invalid_data)
self.middleware.process_response(request, response)
@@ -232,11 +202,7 @@ class TestContractValidationMiddlewareHybridValidation(TestCase):
def test__validate_hybrid_response__valid_strategy__no_violation(self, mock_log):
"""Test valid hybrid response strategy doesn't log violation."""
request = self.factory.get("/api/v1/parks/hybrid/")
valid_data = {
"strategy": "client_side",
"data": [],
"filter_metadata": {}
}
valid_data = {"strategy": "client_side", "data": [], "filter_metadata": {}}
response = JsonResponse(valid_data)
self.middleware.process_response(request, response)
@@ -246,15 +212,10 @@ class TestContractValidationMiddlewareHybridValidation(TestCase):
assert "INVALID_STRATEGY_VALUE" not in str(call)
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
def test__validate_hybrid_response__invalid_strategy__logs_violation(
self, mock_log
):
def test__validate_hybrid_response__invalid_strategy__logs_violation(self, mock_log):
"""Test invalid hybrid strategy logs violation."""
request = self.factory.get("/api/v1/parks/hybrid/")
invalid_data = {
"strategy": "invalid_strategy", # Not 'client_side' or 'server_side'
"data": []
}
invalid_data = {"strategy": "invalid_strategy", "data": []} # Not 'client_side' or 'server_side'
response = JsonResponse(invalid_data)
self.middleware.process_response(request, response)
@@ -277,12 +238,7 @@ class TestContractValidationMiddlewarePaginationValidation(TestCase):
def test__validate_pagination__valid_response__no_violation(self, mock_log):
"""Test valid pagination response doesn't log violation."""
request = self.factory.get("/api/v1/parks/")
valid_data = {
"count": 10,
"next": None,
"previous": None,
"results": [{"id": 1}, {"id": 2}]
}
valid_data = {"count": 10, "next": None, "previous": None, "results": [{"id": 1}, {"id": 2}]}
response = JsonResponse(valid_data)
self.middleware.process_response(request, response)
@@ -296,10 +252,7 @@ class TestContractValidationMiddlewarePaginationValidation(TestCase):
def test__validate_pagination__results_not_array__logs_violation(self, mock_log):
"""Test pagination with non-array results logs violation."""
request = self.factory.get("/api/v1/parks/")
invalid_data = {
"count": 10,
"results": "not an array" # Should be array
}
invalid_data = {"count": 10, "results": "not an array"} # Should be array
response = JsonResponse(invalid_data)
self.middleware.process_response(request, response)
@@ -346,9 +299,7 @@ class TestContractValidationMiddlewareViolationSuggestions(TestCase):
def test__get_violation_suggestion__categorical_string__returns_suggestion(self):
"""Test get_violation_suggestion returns suggestion for CATEGORICAL_OPTION_IS_STRING."""
suggestion = self.middleware._get_violation_suggestion(
"CATEGORICAL_OPTION_IS_STRING"
)
suggestion = self.middleware._get_violation_suggestion("CATEGORICAL_OPTION_IS_STRING")
assert "ensure_filter_option_format" in suggestion
assert "object arrays" in suggestion

View File

@@ -470,6 +470,3 @@ class TestUserProfileUpdateInputSerializer(TestCase):
"""Test user field is read-only for updates."""
extra_kwargs = UserProfileUpdateInputSerializer.Meta.extra_kwargs
assert extra_kwargs.get("user", {}).get("read_only") is True

View File

@@ -221,10 +221,7 @@ class TestParkPhotoListOutputSerializer(TestCase):
def test__meta__all_fields_read_only(self):
"""Test all fields are read-only for list serializer."""
assert (
ParkPhotoListOutputSerializer.Meta.read_only_fields
== ParkPhotoListOutputSerializer.Meta.fields
)
assert ParkPhotoListOutputSerializer.Meta.read_only_fields == ParkPhotoListOutputSerializer.Meta.fields
class TestParkPhotoApprovalInputSerializer(TestCase):
@@ -331,7 +328,7 @@ class TestHybridParkSerializer(TestCase):
"""Test serializing park without location returns null for location fields."""
park = ParkFactory()
# Remove location if it exists
if hasattr(park, 'location') and park.location:
if hasattr(park, "location") and park.location:
park.location.delete()
serializer = HybridParkSerializer(park)
@@ -413,10 +410,7 @@ class TestHybridParkSerializer(TestCase):
def test__meta__all_fields_read_only(self):
"""Test all fields in HybridParkSerializer are read-only."""
assert (
HybridParkSerializer.Meta.read_only_fields
== HybridParkSerializer.Meta.fields
)
assert HybridParkSerializer.Meta.read_only_fields == HybridParkSerializer.Meta.fields
@pytest.mark.django_db

View File

@@ -219,10 +219,7 @@ class TestRidePhotoListOutputSerializer(TestCase):
def test__meta__all_fields_read_only(self):
"""Test all fields are read-only for list serializer."""
assert (
RidePhotoListOutputSerializer.Meta.read_only_fields
== RidePhotoListOutputSerializer.Meta.fields
)
assert RidePhotoListOutputSerializer.Meta.read_only_fields == RidePhotoListOutputSerializer.Meta.fields
class TestRidePhotoApprovalInputSerializer(TestCase):
@@ -477,10 +474,7 @@ class TestHybridRideSerializer(TestCase):
def test__meta__all_fields_read_only(self):
"""Test all fields in HybridRideSerializer are read-only."""
assert (
HybridRideSerializer.Meta.read_only_fields
== HybridRideSerializer.Meta.fields
)
assert HybridRideSerializer.Meta.read_only_fields == HybridRideSerializer.Meta.fields
def test__serialize__includes_ride_model_fields(self):
"""Test serializing includes ride model information."""

View File

@@ -43,9 +43,7 @@ class TestParkMediaServiceUploadPhoto(TestCase):
park = ParkFactory()
user = UserFactory()
image_file = SimpleUploadedFile(
"test.jpg", b"fake image content", content_type="image/jpeg"
)
image_file = SimpleUploadedFile("test.jpg", b"fake image content", content_type="image/jpeg")
photo = ParkMediaService.upload_photo(
park=park,
@@ -70,9 +68,7 @@ class TestParkMediaServiceUploadPhoto(TestCase):
park = ParkFactory()
user = UserFactory()
image_file = SimpleUploadedFile(
"test.txt", b"not an image", content_type="text/plain"
)
image_file = SimpleUploadedFile("test.txt", b"not an image", content_type="text/plain")
with pytest.raises(ValueError) as exc_info:
ParkMediaService.upload_photo(

View File

@@ -104,7 +104,9 @@ class TestRideServiceCreateRide(TestCase):
def test__create_ride__invalid_park__raises_exception(self):
"""Test create_ride raises exception for invalid park."""
with pytest.raises(Exception):
from apps.parks.models import Park
with pytest.raises(Park.DoesNotExist):
RideService.create_ride(
name="Test Ride",
park_id=99999, # Non-existent
@@ -274,9 +276,7 @@ class TestRideServiceHandleNewEntitySuggestions(TestCase):
"""Tests for RideService.handle_new_entity_suggestions."""
@patch("apps.rides.services.ModerationService.create_edit_submission_with_queue")
def test__handle_new_entity_suggestions__new_manufacturer__creates_submission(
self, mock_create_submission
):
def test__handle_new_entity_suggestions__new_manufacturer__creates_submission(self, mock_create_submission):
"""Test handle_new_entity_suggestions creates submission for new manufacturer."""
mock_submission = Mock()
mock_submission.id = 1
@@ -302,9 +302,7 @@ class TestRideServiceHandleNewEntitySuggestions(TestCase):
mock_create_submission.assert_called_once()
@patch("apps.rides.services.ModerationService.create_edit_submission_with_queue")
def test__handle_new_entity_suggestions__new_designer__creates_submission(
self, mock_create_submission
):
def test__handle_new_entity_suggestions__new_designer__creates_submission(self, mock_create_submission):
"""Test handle_new_entity_suggestions creates submission for new designer."""
mock_submission = Mock()
mock_submission.id = 2
@@ -329,9 +327,7 @@ class TestRideServiceHandleNewEntitySuggestions(TestCase):
assert 2 in result["designers"]
@patch("apps.rides.services.ModerationService.create_edit_submission_with_queue")
def test__handle_new_entity_suggestions__new_ride_model__creates_submission(
self, mock_create_submission
):
def test__handle_new_entity_suggestions__new_ride_model__creates_submission(self, mock_create_submission):
"""Test handle_new_entity_suggestions creates submission for new ride model."""
mock_submission = Mock()
mock_submission.id = 3

View File

@@ -196,9 +196,7 @@ class FactoryValidationTestCase(TestCase):
from datetime import date
# Valid dates
park = ParkFactory.build(
opening_date=date(2020, 1, 1), closing_date=date(2023, 12, 31)
)
park = ParkFactory.build(opening_date=date(2020, 1, 1), closing_date=date(2023, 12, 31))
# Verify opening is before closing
if park.opening_date and park.closing_date:

View File

@@ -75,9 +75,7 @@ class TestParkListApi(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Should return only operating parks (2 out of 3)
operating_parks = [
p for p in response.data["data"] if p["status"] == "OPERATING"
]
operating_parks = [p for p in response.data["data"] if p["status"] == "OPERATING"]
self.assertEqual(len(operating_parks), 2)
def test__park_list_api__with_search_query__returns_matching_results(self):
@@ -315,9 +313,7 @@ class TestParkApiErrorHandling(APITestCase):
"""Test that malformed JSON returns proper error."""
url = reverse("parks_api:park-list")
response = self.client.post(
url, data='{"invalid": json}', content_type="application/json"
)
response = self.client.post(url, data='{"invalid": json}', content_type="application/json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data["status"], "error")
@@ -358,9 +354,7 @@ class TestParkApiIntegration(APITestCase):
retrieve_response = self.client.get(detail_url)
self.assertEqual(retrieve_response.status_code, status.HTTP_200_OK)
self.assertEqual(
retrieve_response.data["data"]["name"], "Integration Test Park"
)
self.assertEqual(retrieve_response.data["data"]["name"], "Integration Test Park")
# 3. Update park
update_data = {"description": "Updated integration test description"}

View File

@@ -81,9 +81,7 @@ class ApiTestMixin:
error_code: Expected error code in response
message_contains: String that should be in error message
"""
self.assertApiResponse(
response, status_code=status_code, response_status="error"
)
self.assertApiResponse(response, status_code=status_code, response_status="error")
if error_code:
self.assertEqual(response.data["error"]["code"], error_code)
@@ -289,9 +287,7 @@ class GeographyTestMixin:
self.assertGreaterEqual(longitude, -180, "Longitude below valid range")
self.assertLessEqual(longitude, 180, "Longitude above valid range")
def assertCoordinateDistance(
self, point1: tuple, point2: tuple, max_distance_km: float
):
def assertCoordinateDistance(self, point1: tuple, point2: tuple, max_distance_km: float):
"""
Assert two geographic points are within specified distance.

View File

@@ -8,10 +8,17 @@ Reusable utility functions for testing FSM transitions:
- Toast notification verification utilities
"""
from __future__ import annotations
import json
from typing import Any
from typing import TYPE_CHECKING, Any
from django.contrib.auth import get_user_model
if TYPE_CHECKING:
from apps.moderation.models import EditSubmission, PhotoSubmission
from apps.parks.models import Park
from apps.rides.models import Ride
from django.contrib.contenttypes.models import ContentType
from django.db.models import Model
from django.http import HttpResponse
@@ -31,8 +38,8 @@ def create_test_submission(
submission_type: str = "EDIT",
changes: dict[str, Any] | None = None,
reason: str = "Test submission",
**kwargs
) -> "EditSubmission":
**kwargs,
) -> EditSubmission:
"""
Create a test EditSubmission with the given status.
@@ -54,8 +61,7 @@ def create_test_submission(
# Get or create user
if user is None:
user, _ = User.objects.get_or_create(
username="test_submitter",
defaults={"email": "test_submitter@example.com"}
username="test_submitter", defaults={"email": "test_submitter@example.com"}
)
user.set_password("testpass123")
user.save()
@@ -80,18 +86,13 @@ def create_test_submission(
changes=changes,
reason=reason,
status=status,
**kwargs
**kwargs,
)
return submission
def create_test_park(
status: str = "OPERATING",
name: str | None = None,
slug: str | None = None,
**kwargs
) -> "Park":
def create_test_park(status: str = "OPERATING", name: str | None = None, slug: str | None = None, **kwargs) -> Park:
"""
Create a test Park with the given status.
@@ -108,29 +109,22 @@ def create_test_park(
if name is None:
import random
name = f"Test Park {random.randint(1000, 9999)}"
if slug is None:
from django.utils.text import slugify
slug = slugify(name)
park = ParkFactory(
name=name,
slug=slug,
status=status,
**kwargs
)
park = ParkFactory(name=name, slug=slug, status=status, **kwargs)
return park
def create_test_ride(
status: str = "OPERATING",
name: str | None = None,
slug: str | None = None,
park: Model | None = None,
**kwargs
) -> "Ride":
status: str = "OPERATING", name: str | None = None, slug: str | None = None, park: Model | None = None, **kwargs
) -> Ride:
"""
Create a test Ride with the given status.
@@ -148,18 +142,15 @@ def create_test_ride(
if name is None:
import random
name = f"Test Ride {random.randint(1000, 9999)}"
if slug is None:
from django.utils.text import slugify
slug = slugify(name)
ride_kwargs = {
"name": name,
"slug": slug,
"status": status,
**kwargs
}
ride_kwargs = {"name": name, "slug": slug, "status": status, **kwargs}
if park is not None:
ride_kwargs["park"] = park
@@ -170,11 +161,8 @@ def create_test_ride(
def create_test_photo_submission(
status: str = "PENDING",
user: User | None = None,
park: Model | None = None,
**kwargs
) -> "PhotoSubmission":
status: str = "PENDING", user: User | None = None, park: Model | None = None, **kwargs
) -> PhotoSubmission:
"""
Create a test PhotoSubmission with the given status.
@@ -193,8 +181,7 @@ def create_test_photo_submission(
# Get or create user
if user is None:
user, _ = User.objects.get_or_create(
username="test_photo_submitter",
defaults={"email": "test_photo@example.com"}
username="test_photo_submitter", defaults={"email": "test_photo@example.com"}
)
user.set_password("testpass123")
user.save()
@@ -210,11 +197,12 @@ def create_test_photo_submission(
# Get a photo if available
try:
from django_cloudflareimages_toolkit.models import CloudflareImage
photo = CloudflareImage.objects.first()
if not photo:
raise ValueError("No CloudflareImage available for testing")
except ImportError:
raise ValueError("CloudflareImage not available")
raise ValueError("CloudflareImage not available") from None
submission = PhotoSubmission.objects.create(
user=user,
@@ -223,7 +211,7 @@ def create_test_photo_submission(
photo=photo,
caption="Test photo submission",
status=status,
**kwargs
**kwargs,
)
return submission
@@ -247,16 +235,11 @@ def assert_status_changed(obj: Model, expected_status: str) -> None:
"""
obj.refresh_from_db()
actual_status = getattr(obj, "status", None)
assert actual_status == expected_status, (
f"Expected status '{expected_status}', got '{actual_status}'"
)
assert actual_status == expected_status, f"Expected status '{expected_status}', got '{actual_status}'"
def assert_state_log_created(
obj: Model,
transition_name: str,
user: User | None = None,
expected_state: str | None = None
obj: Model, transition_name: str, user: User | None = None, expected_state: str | None = None
) -> None:
"""
Assert that a StateLog entry was created for a transition.
@@ -274,31 +257,20 @@ def assert_state_log_created(
content_type = ContentType.objects.get_for_model(obj)
logs = StateLog.objects.filter(
content_type=content_type,
object_id=obj.pk
).order_by('-timestamp')
logs = StateLog.objects.filter(content_type=content_type, object_id=obj.pk).order_by("-timestamp")
assert logs.exists(), "No StateLog entries found for object"
latest_log = logs.first()
if expected_state is not None:
assert latest_log.state == expected_state, (
f"Expected state '{expected_state}' in log, got '{latest_log.state}'"
)
assert latest_log.state == expected_state, f"Expected state '{expected_state}' in log, got '{latest_log.state}'"
if user is not None:
assert latest_log.by == user, (
f"Expected log by user '{user}', got '{latest_log.by}'"
)
assert latest_log.by == user, f"Expected log by user '{user}', got '{latest_log.by}'"
def assert_toast_triggered(
response: HttpResponse,
message: str | None = None,
toast_type: str = "success"
) -> None:
def assert_toast_triggered(response: HttpResponse, message: str | None = None, toast_type: str = "success") -> None:
"""
Assert that the response contains an HX-Trigger header with toast data.
@@ -316,14 +288,12 @@ def assert_toast_triggered(
assert "showToast" in trigger_data, "HX-Trigger missing showToast event"
toast_data = trigger_data["showToast"]
assert toast_data.get("type") == toast_type, (
f"Expected toast type '{toast_type}', got '{toast_data.get('type')}'"
)
assert toast_data.get("type") == toast_type, f"Expected toast type '{toast_type}', got '{toast_data.get('type')}'"
if message is not None:
assert message in toast_data.get("message", ""), (
f"Expected '{message}' in toast message, got '{toast_data.get('message')}'"
)
assert message in toast_data.get(
"message", ""
), f"Expected '{message}' in toast message, got '{toast_data.get('message')}'"
def assert_no_status_change(obj: Model, original_status: str) -> None:
@@ -339,9 +309,9 @@ def assert_no_status_change(obj: Model, original_status: str) -> None:
"""
obj.refresh_from_db()
actual_status = getattr(obj, "status", None)
assert actual_status == original_status, (
f"Status should not have changed from '{original_status}', but is now '{actual_status}'"
)
assert (
actual_status == original_status
), f"Status should not have changed from '{original_status}', but is now '{actual_status}'"
# =============================================================================
@@ -349,11 +319,7 @@ def assert_no_status_change(obj: Model, original_status: str) -> None:
# =============================================================================
def wait_for_htmx_swap(
page,
target_selector: str,
timeout: int = 5000
) -> None:
def wait_for_htmx_swap(page, target_selector: str, timeout: int = 5000) -> None:
"""
Wait for an HTMX swap to complete on a target element.
@@ -370,14 +336,12 @@ def wait_for_htmx_swap(
return el && !el.classList.contains('htmx-request');
}}
""",
timeout=timeout
timeout=timeout,
)
def verify_transition_buttons_visible(
page,
transitions: list[str],
container_selector: str = "[data-status-actions]"
page, transitions: list[str], container_selector: str = "[data-status-actions]"
) -> dict[str, bool]:
"""
Verify which transition buttons are visible on the page.
@@ -447,11 +411,7 @@ def wait_for_toast(page, toast_selector: str = "[data-toast]", timeout: int = 50
return toast
def wait_for_toast_dismiss(
page,
toast_selector: str = "[data-toast]",
timeout: int = 10000
) -> None:
def wait_for_toast_dismiss(page, toast_selector: str = "[data-toast]", timeout: int = 10000) -> None:
"""
Wait for a toast notification to be dismissed.
@@ -473,6 +433,7 @@ def click_and_confirm(page, button_locator, accept: bool = True) -> None:
button_locator: The button locator to click
accept: Whether to accept (True) or dismiss (False) the dialog
"""
def handle_dialog(dialog):
if accept:
dialog.accept()
@@ -500,11 +461,7 @@ def make_htmx_post(client, url: str, data: dict | None = None) -> HttpResponse:
Returns:
HttpResponse from the request
"""
return client.post(
url,
data=data or {},
HTTP_HX_REQUEST="true"
)
return client.post(url, data=data or {}, HTTP_HX_REQUEST="true")
def make_htmx_get(client, url: str) -> HttpResponse:
@@ -518,19 +475,11 @@ def make_htmx_get(client, url: str) -> HttpResponse:
Returns:
HttpResponse from the request
"""
return client.get(
url,
HTTP_HX_REQUEST="true"
)
return client.get(url, HTTP_HX_REQUEST="true")
def get_fsm_transition_url(
app_label: str,
model_name: str,
pk: int,
transition_name: str,
use_slug: bool = False,
slug: str | None = None
app_label: str, model_name: str, pk: int, transition_name: str, use_slug: bool = False, slug: str | None = None
) -> str:
"""
Generate the URL for an FSM transition.
@@ -553,20 +502,10 @@ def get_fsm_transition_url(
raise ValueError("slug is required when use_slug is True")
return reverse(
"core:fsm_transition_by_slug",
kwargs={
"app_label": app_label,
"model_name": model_name,
"slug": slug,
"transition_name": transition_name
}
kwargs={"app_label": app_label, "model_name": model_name, "slug": slug, "transition_name": transition_name},
)
else:
return reverse(
"core:fsm_transition",
kwargs={
"app_label": app_label,
"model_name": model_name,
"pk": pk,
"transition_name": transition_name
}
kwargs={"app_label": app_label, "model_name": model_name, "pk": pk, "transition_name": transition_name},
)

View File

@@ -142,9 +142,7 @@ class TestBreadcrumbBuilder:
def test_schema_positions_auto_assigned(self):
"""Should auto-assign schema positions."""
builder = BreadcrumbBuilder()
crumbs = (
builder.add_home().add("Parks", "/parks/").add_current("Test").build()
)
crumbs = builder.add_home().add("Parks", "/parks/").add_current("Test").build()
assert crumbs[0].schema_position == 1
assert crumbs[1].schema_position == 2

View File

@@ -5,7 +5,6 @@ These tests verify that message helper functions generate
consistent, user-friendly messages.
"""
from apps.core.utils.messages import (
confirm_delete,
error_not_found,
@@ -131,8 +130,4 @@ class TestConfirmMessages:
def test_confirm_delete_warning(self):
"""Should include warning about irreversibility."""
message = confirm_delete("Park")
assert (
"cannot be undone" in message.lower()
or "permanent" in message.lower()
or "sure" in message.lower()
)
assert "cannot be undone" in message.lower() or "permanent" in message.lower() or "sure" in message.lower()