mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-01-02 00:07:02 -05:00
feat: Implement initial schema and add various API, service, and management command enhancements across the application.
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/")
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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/")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ Tests for Park forms.
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ Tests for Ride forms.
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user