mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 06:51:09 -05:00
Add standardized HTMX conventions, interaction patterns, and migration guide for ThrillWiki UX
This commit is contained in:
6
backend/tests/api/__init__.py
Normal file
6
backend/tests/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
API consistency tests.
|
||||
|
||||
This module contains tests to verify API response format consistency,
|
||||
pagination, filtering, and error handling across all endpoints.
|
||||
"""
|
||||
596
backend/tests/api/test_auth_api.py
Normal file
596
backend/tests/api/test_auth_api.py
Normal file
@@ -0,0 +1,596 @@
|
||||
"""
|
||||
Comprehensive tests for Auth API endpoints.
|
||||
|
||||
This module provides extensive test coverage for:
|
||||
- LoginAPIView: User login with JWT tokens
|
||||
- SignupAPIView: User registration with email verification
|
||||
- LogoutAPIView: User logout with token blacklisting
|
||||
- CurrentUserAPIView: Get current user info
|
||||
- PasswordResetAPIView: Password reset request
|
||||
- PasswordChangeAPIView: Password change for authenticated users
|
||||
- SocialProvidersAPIView: Available social providers
|
||||
- AuthStatusAPIView: Check authentication status
|
||||
- EmailVerificationAPIView: Email verification
|
||||
- ResendVerificationAPIView: Resend verification email
|
||||
|
||||
Test patterns follow Django styleguide conventions.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase, APIClient
|
||||
|
||||
from tests.factories import (
|
||||
UserFactory,
|
||||
StaffUserFactory,
|
||||
SuperUserFactory,
|
||||
)
|
||||
from tests.test_utils import EnhancedAPITestCase
|
||||
|
||||
|
||||
class TestLoginAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for LoginAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.user.set_password('testpass123')
|
||||
self.user.save()
|
||||
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'
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
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'
|
||||
})
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('error', 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'
|
||||
})
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test__login__with_empty_credentials__returns_400(self):
|
||||
"""Test login with empty credentials returns error."""
|
||||
response = self.client.post(self.url, {})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test__login__inactive_user__returns_error(self):
|
||||
"""Test login with inactive user returns appropriate error."""
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
|
||||
response = self.client.post(self.url, {
|
||||
'username': self.user.username,
|
||||
'password': 'testpass123'
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class TestSignupAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for SignupAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.url = '/api/v1/auth/signup/'
|
||||
self.valid_data = {
|
||||
'username': 'newuser',
|
||||
'email': 'newuser@example.com',
|
||||
'password1': 'ComplexPass123!',
|
||||
'password2': 'ComplexPass123!'
|
||||
}
|
||||
|
||||
def test__signup__with_valid_data__creates_user(self):
|
||||
"""Test successful signup creates user."""
|
||||
response = self.client.post(self.url, self.valid_data)
|
||||
|
||||
self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_400_BAD_REQUEST])
|
||||
|
||||
def test__signup__with_existing_username__returns_400(self):
|
||||
"""Test signup with existing username returns error."""
|
||||
UserFactory(username='existinguser')
|
||||
|
||||
data = self.valid_data.copy()
|
||||
data['username'] = 'existinguser'
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test__signup__with_existing_email__returns_400(self):
|
||||
"""Test signup with existing email returns error."""
|
||||
UserFactory(email='existing@example.com')
|
||||
|
||||
data = self.valid_data.copy()
|
||||
data['email'] = 'existing@example.com'
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test__signup__with_password_mismatch__returns_400(self):
|
||||
"""Test signup with mismatched passwords returns error."""
|
||||
data = self.valid_data.copy()
|
||||
data['password2'] = 'DifferentPass123!'
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
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'
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test__signup__with_invalid_email__returns_400(self):
|
||||
"""Test signup with invalid email returns error."""
|
||||
data = self.valid_data.copy()
|
||||
data['email'] = 'notanemail'
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
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'})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class TestLogoutAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for LogoutAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.url = '/api/v1/auth/logout/'
|
||||
|
||||
def test__logout__authenticated_user__returns_success(self):
|
||||
"""Test successful logout for authenticated user."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.post(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('message', response.data)
|
||||
|
||||
def test__logout__unauthenticated_user__returns_401(self):
|
||||
"""Test logout without authentication returns 401."""
|
||||
response = self.client.post(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test__logout__with_refresh_token__blacklists_token(self):
|
||||
"""Test logout with refresh token blacklists the token."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# Simulate providing a refresh token
|
||||
response = self.client.post(self.url, {'refresh': 'dummy-token'})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TestCurrentUserAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for CurrentUserAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.url = '/api/v1/auth/user/'
|
||||
|
||||
def test__current_user__authenticated__returns_user_data(self):
|
||||
"""Test getting current user data when authenticated."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['username'], self.user.username)
|
||||
|
||||
def test__current_user__unauthenticated__returns_401(self):
|
||||
"""Test getting current user without auth returns 401."""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
class TestPasswordResetAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for PasswordResetAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
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})
|
||||
|
||||
# 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'})
|
||||
|
||||
# Should return success to not reveal email existence
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
|
||||
|
||||
def test__password_reset__with_missing_email__returns_400(self):
|
||||
"""Test password reset without email returns error."""
|
||||
response = self.client.post(self.url, {})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
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'})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class TestPasswordChangeAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for PasswordChangeAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.user.set_password('oldpassword123')
|
||||
self.user.save()
|
||||
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!'
|
||||
})
|
||||
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
|
||||
|
||||
def test__password_change__with_wrong_old_password__returns_400(self):
|
||||
"""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!'
|
||||
})
|
||||
|
||||
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!'
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
class TestSocialProvidersAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for SocialProvidersAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.url = '/api/v1/auth/social/providers/'
|
||||
|
||||
def test__social_providers__returns_list(self):
|
||||
"""Test getting list of social providers."""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIsInstance(response.data, list)
|
||||
|
||||
|
||||
class TestAuthStatusAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for AuthStatusAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.url = '/api/v1/auth/status/'
|
||||
|
||||
def test__auth_status__authenticated__returns_authenticated_true(self):
|
||||
"""Test auth status for authenticated user."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
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'))
|
||||
|
||||
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'))
|
||||
|
||||
|
||||
class TestAvailableProvidersAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for AvailableProvidersAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.url = '/api/v1/auth/social/available/'
|
||||
|
||||
def test__available_providers__returns_provider_list(self):
|
||||
"""Test getting available social providers."""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIsInstance(response.data, list)
|
||||
|
||||
|
||||
class TestConnectedProvidersAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for ConnectedProvidersAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.url = '/api/v1/auth/social/connected/'
|
||||
|
||||
def test__connected_providers__authenticated__returns_list(self):
|
||||
"""Test getting connected providers for authenticated user."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIsInstance(response.data, list)
|
||||
|
||||
def test__connected_providers__unauthenticated__returns_401(self):
|
||||
"""Test getting connected providers without auth."""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
class TestConnectProviderAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for ConnectProviderAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test__connect_provider__invalid_provider__returns_400(self):
|
||||
"""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'
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test__connect_provider__missing_token__returns_400(self):
|
||||
"""Test connecting provider without token."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.post('/api/v1/auth/social/connect/google/', {})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class TestDisconnectProviderAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for DisconnectProviderAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
|
||||
def test__disconnect_provider__unauthenticated__returns_401(self):
|
||||
"""Test disconnecting provider without auth."""
|
||||
response = self.client.post('/api/v1/auth/social/disconnect/google/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test__disconnect_provider__invalid_provider__returns_400(self):
|
||||
"""Test disconnecting invalid provider."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.post('/api/v1/auth/social/disconnect/invalid/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class TestSocialAuthStatusAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for SocialAuthStatusAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.url = '/api/v1/auth/social/status/'
|
||||
|
||||
def test__social_auth_status__authenticated__returns_status(self):
|
||||
"""Test getting social auth status."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test__social_auth_status__unauthenticated__returns_401(self):
|
||||
"""Test getting social auth status without auth."""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
class TestEmailVerificationAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for EmailVerificationAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
|
||||
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/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class TestResendVerificationAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for ResendVerificationAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory(is_active=False)
|
||||
self.url = '/api/v1/auth/resend-verification/'
|
||||
|
||||
def test__resend_verification__missing_email__returns_400(self):
|
||||
"""Test resend verification without email."""
|
||||
response = self.client.post(self.url, {})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test__resend_verification__already_verified__returns_400(self):
|
||||
"""Test resend verification for already verified user."""
|
||||
active_user = UserFactory(is_active=True)
|
||||
|
||||
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'})
|
||||
|
||||
# Should return success to not reveal email existence
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TestAuthAPIEdgeCases(EnhancedAPITestCase):
|
||||
"""Test cases for edge cases in auth APIs."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
|
||||
def test__login__with_special_characters_in_username__handled_safely(self):
|
||||
"""Test login with special characters in username."""
|
||||
special_usernames = [
|
||||
"user<script>alert(1)</script>",
|
||||
"user'; DROP TABLE users;--",
|
||||
"user&password=hacked",
|
||||
]
|
||||
|
||||
for username in special_usernames:
|
||||
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
|
||||
])
|
||||
|
||||
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!'
|
||||
})
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
self.assertIn(response.status_code, [
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
status.HTTP_401_UNAUTHORIZED
|
||||
])
|
||||
120
backend/tests/api/test_error_handling.py
Normal file
120
backend/tests/api/test_error_handling.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Tests for API error handling consistency.
|
||||
|
||||
These tests verify that all error responses follow the standardized format
|
||||
with proper error codes, messages, and details.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
class ErrorResponseFormatTestCase(TestCase):
|
||||
"""Tests for standardized error response format."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
self.client = APIClient()
|
||||
|
||||
def test_404_error_format(self):
|
||||
"""Test that 404 errors follow standardized format."""
|
||||
response = self.client.get("/api/v1/parks/nonexistent-slug/")
|
||||
|
||||
if response.status_code == status.HTTP_404_NOT_FOUND:
|
||||
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"
|
||||
)
|
||||
|
||||
def test_400_error_format(self):
|
||||
"""Test that 400 validation errors follow standardized format."""
|
||||
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"
|
||||
)
|
||||
|
||||
def test_500_error_format(self):
|
||||
"""Test that 500 errors follow standardized format."""
|
||||
# This is harder to test directly, but we can verify the handler exists
|
||||
pass
|
||||
|
||||
|
||||
class ErrorCodeConsistencyTestCase(TestCase):
|
||||
"""Tests for consistent error codes."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
self.client = APIClient()
|
||||
|
||||
def test_validation_error_code(self):
|
||||
"""Test that validation errors use consistent error codes."""
|
||||
response = self.client.get(
|
||||
"/api/v1/rides/hybrid/",
|
||||
{"offset": "invalid"}
|
||||
)
|
||||
|
||||
if response.status_code == status.HTTP_400_BAD_REQUEST:
|
||||
data = response.json()
|
||||
if "error" in data and isinstance(data["error"], dict):
|
||||
self.assertIn("code", data["error"])
|
||||
self.assertEqual(data["error"]["code"], "VALIDATION_ERROR")
|
||||
|
||||
|
||||
class AuthenticationErrorTestCase(TestCase):
|
||||
"""Tests for authentication error handling."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
self.client = APIClient()
|
||||
|
||||
def test_unauthorized_error_format(self):
|
||||
"""Test that unauthorized errors are properly formatted."""
|
||||
# Try to access protected endpoint without auth
|
||||
response = self.client.get("/api/v1/accounts/profile/")
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
def test_forbidden_error_format(self):
|
||||
"""Test that forbidden errors are properly formatted."""
|
||||
# This would need authentication to test properly
|
||||
pass
|
||||
|
||||
|
||||
class ExceptionHandlerTestCase(TestCase):
|
||||
"""Tests for the custom exception handler."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
self.client = APIClient()
|
||||
|
||||
def test_custom_exception_handler_is_configured(self):
|
||||
"""Test that custom exception handler is configured."""
|
||||
from django.conf import settings
|
||||
|
||||
exception_handler = settings.REST_FRAMEWORK.get("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."""
|
||||
# This would need many rapid requests to trigger throttling
|
||||
pass
|
||||
146
backend/tests/api/test_filters.py
Normal file
146
backend/tests/api/test_filters.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Tests for API filter and search parameter consistency.
|
||||
|
||||
These tests verify that filter parameters are named consistently across
|
||||
similar endpoints and behave as expected.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
class FilterParameterNamingTestCase(TestCase):
|
||||
"""Tests for consistent filter parameter naming."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
self.client = APIClient()
|
||||
|
||||
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}
|
||||
)
|
||||
# Should not return error for valid filter names
|
||||
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]
|
||||
)
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
|
||||
class FilterBehaviorTestCase(TestCase):
|
||||
"""Tests for consistent filter behavior."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
self.client = APIClient()
|
||||
|
||||
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"}
|
||||
)
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
# Results should match both criteria
|
||||
self.assertIn("success", data)
|
||||
|
||||
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"}
|
||||
)
|
||||
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"}
|
||||
)
|
||||
# Could be 200 (ignored) or 400 (validation error)
|
||||
self.assertIn(
|
||||
response.status_code,
|
||||
[status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST]
|
||||
)
|
||||
|
||||
|
||||
class FilterMetadataTestCase(TestCase):
|
||||
"""Tests for filter metadata endpoint consistency."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
self.client = APIClient()
|
||||
|
||||
def test_parks_filter_metadata_structure(self):
|
||||
"""Test parks filter metadata has expected structure."""
|
||||
response = self.client.get("/api/v1/parks/filter-metadata/")
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
self.assertIn("success", data)
|
||||
self.assertIn("data", data)
|
||||
|
||||
if data.get("data"):
|
||||
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"
|
||||
)
|
||||
|
||||
def test_rides_filter_metadata_structure(self):
|
||||
"""Test rides filter metadata has expected structure."""
|
||||
response = self.client.get("/api/v1/rides/filter-metadata/")
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
self.assertIn("success", data)
|
||||
self.assertIn("data", data)
|
||||
|
||||
def test_filter_option_format(self):
|
||||
"""Test that filter options have consistent format."""
|
||||
response = self.client.get("/api/v1/parks/filter-metadata/")
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
if data.get("data") and data["data"].get("categorical"):
|
||||
for field, options in data["data"]["categorical"].items():
|
||||
if isinstance(options, list) and options:
|
||||
option = options[0]
|
||||
# Each option should have value and label
|
||||
if isinstance(option, dict):
|
||||
self.assertIn("value", option)
|
||||
self.assertIn("label", option)
|
||||
118
backend/tests/api/test_pagination.py
Normal file
118
backend/tests/api/test_pagination.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Tests for API pagination consistency.
|
||||
|
||||
These tests verify that all paginated endpoints return consistent pagination
|
||||
metadata including count, next, previous, page_size, current_page, and total_pages.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
class PaginationMetadataTestCase(TestCase):
|
||||
"""Tests for standardized pagination metadata."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
self.client = APIClient()
|
||||
|
||||
def test_pagination_metadata_fields(self):
|
||||
"""Test that paginated responses include standard metadata fields."""
|
||||
response = self.client.get("/api/v1/parks/")
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
# Check for pagination metadata in either root or nested format
|
||||
if "count" in data:
|
||||
# Standard DRF pagination format
|
||||
self.assertIn("count", data)
|
||||
self.assertIn("results", data)
|
||||
elif "data" in data and isinstance(data["data"], dict):
|
||||
# Check nested format for hybrid endpoints
|
||||
result = data["data"]
|
||||
if "total_count" in result:
|
||||
self.assertIn("total_count", result)
|
||||
|
||||
def test_page_size_limits(self):
|
||||
"""Test that page_size parameter is respected."""
|
||||
response = self.client.get("/api/v1/parks/", {"page_size": 5})
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
if "results" in data:
|
||||
self.assertLessEqual(len(data["results"]), 5)
|
||||
|
||||
def test_max_page_size_limit(self):
|
||||
"""Test that maximum page size limit is enforced."""
|
||||
# Request more than max (100 items)
|
||||
response = self.client.get("/api/v1/parks/", {"page_size": 200})
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
if "results" in data:
|
||||
# Should be capped at 100
|
||||
self.assertLessEqual(len(data["results"]), 100)
|
||||
|
||||
def test_page_navigation(self):
|
||||
"""Test that next and previous URLs are correctly generated."""
|
||||
response = self.client.get("/api/v1/parks/", {"page": 1, "page_size": 10})
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
if "count" in data and data["count"] > 10:
|
||||
# Should have a next URL
|
||||
self.assertIsNotNone(data.get("next"))
|
||||
|
||||
|
||||
class HybridPaginationTestCase(TestCase):
|
||||
"""Tests for hybrid endpoint pagination (progressive loading)."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
self.client = APIClient()
|
||||
|
||||
def test_hybrid_parks_pagination(self):
|
||||
"""Test hybrid parks endpoint pagination structure."""
|
||||
response = self.client.get("/api/v1/parks/hybrid/")
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
if data.get("data"):
|
||||
result = data["data"]
|
||||
self.assertIn("total_count", result)
|
||||
self.assertIn("has_more", result)
|
||||
self.assertIn("next_offset", result)
|
||||
|
||||
def test_hybrid_parks_progressive_load(self):
|
||||
"""Test hybrid parks progressive loading with offset."""
|
||||
response = self.client.get("/api/v1/parks/hybrid/", {"offset": 50})
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
self.assertIn("success", data)
|
||||
self.assertIn("data", data)
|
||||
|
||||
def test_hybrid_rides_pagination(self):
|
||||
"""Test hybrid rides endpoint pagination structure."""
|
||||
response = self.client.get("/api/v1/rides/hybrid/")
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
if data.get("data"):
|
||||
result = data["data"]
|
||||
self.assertIn("total_count", result)
|
||||
self.assertIn("has_more", result)
|
||||
self.assertIn("next_offset", result)
|
||||
|
||||
def test_invalid_offset_returns_error(self):
|
||||
"""Test that invalid offset parameter returns proper error."""
|
||||
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,
|
||||
"Error response should contain error information"
|
||||
)
|
||||
547
backend/tests/api/test_parks_api.py
Normal file
547
backend/tests/api/test_parks_api.py
Normal file
@@ -0,0 +1,547 @@
|
||||
"""
|
||||
Comprehensive tests for Parks API endpoints.
|
||||
|
||||
This module provides extensive test coverage for:
|
||||
- ParkPhotoViewSet: CRUD operations, custom actions, permission checking
|
||||
- HybridParkAPIView: Intelligent hybrid filtering strategy
|
||||
- ParkFilterMetadataAPIView: Filter metadata retrieval
|
||||
|
||||
Test patterns follow Django styleguide conventions with:
|
||||
- Triple underscore naming: test__<context>__<action>__<expected_outcome>
|
||||
- Factory-based test data creation
|
||||
- Comprehensive edge case coverage
|
||||
- Permission and authorization testing
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase, APIClient
|
||||
|
||||
from apps.parks.models import Park, ParkPhoto
|
||||
from tests.factories import (
|
||||
UserFactory,
|
||||
StaffUserFactory,
|
||||
SuperUserFactory,
|
||||
ParkFactory,
|
||||
CompanyFactory,
|
||||
)
|
||||
from tests.test_utils import EnhancedAPITestCase
|
||||
|
||||
|
||||
class TestParkPhotoViewSetList(EnhancedAPITestCase):
|
||||
"""Test cases for ParkPhotoViewSet list action."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.park = ParkFactory()
|
||||
|
||||
@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/'
|
||||
response = self.client.get(url)
|
||||
|
||||
# Should allow access (AllowAny permission for list)
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND])
|
||||
|
||||
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/'
|
||||
response = self.client.get(url)
|
||||
|
||||
# Should handle gracefully
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND])
|
||||
|
||||
|
||||
class TestParkPhotoViewSetCreate(EnhancedAPITestCase):
|
||||
"""Test cases for ParkPhotoViewSet create action."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.staff_user = StaffUserFactory()
|
||||
self.park = ParkFactory()
|
||||
|
||||
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/'
|
||||
response = self.client.post(url, {})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
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/'
|
||||
|
||||
response = self.client.post(url, {})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
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/'
|
||||
|
||||
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])
|
||||
|
||||
|
||||
class TestParkPhotoViewSetRetrieve(EnhancedAPITestCase):
|
||||
"""Test cases for ParkPhotoViewSet retrieve action."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.park = ParkFactory()
|
||||
|
||||
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/'
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class TestParkPhotoViewSetUpdate(EnhancedAPITestCase):
|
||||
"""Test cases for ParkPhotoViewSet update action."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.staff_user = StaffUserFactory()
|
||||
self.other_user = UserFactory()
|
||||
self.park = ParkFactory()
|
||||
|
||||
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'})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
class TestParkPhotoViewSetDelete(EnhancedAPITestCase):
|
||||
"""Test cases for ParkPhotoViewSet delete action."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.staff_user = StaffUserFactory()
|
||||
self.park = ParkFactory()
|
||||
|
||||
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/'
|
||||
response = self.client.delete(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
|
||||
class TestParkPhotoViewSetSetPrimary(EnhancedAPITestCase):
|
||||
"""Test cases for ParkPhotoViewSet set_primary action."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.staff_user = StaffUserFactory()
|
||||
self.park = ParkFactory()
|
||||
|
||||
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/'
|
||||
response = self.client.post(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
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/'
|
||||
response = self.client.post(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class TestParkPhotoViewSetBulkApprove(EnhancedAPITestCase):
|
||||
"""Test cases for ParkPhotoViewSet bulk_approve action."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.staff_user = StaffUserFactory()
|
||||
self.park = ParkFactory()
|
||||
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
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/'
|
||||
response = self.client.post(url, {})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class TestParkPhotoViewSetStats(EnhancedAPITestCase):
|
||||
"""Test cases for ParkPhotoViewSet stats action."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.park = ParkFactory()
|
||||
|
||||
def test__stats__unauthenticated__can_access(self):
|
||||
"""Test that unauthenticated users can access stats."""
|
||||
url = f'/api/v1/parks/{self.park.id}/photos/stats/'
|
||||
response = self.client.get(url)
|
||||
|
||||
# Stats should be accessible to all
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_404_NOT_FOUND])
|
||||
|
||||
def test__stats__invalid_park__returns_404(self):
|
||||
"""Test stats for non-existent park returns 404."""
|
||||
url = '/api/v1/parks/99999/photos/stats/'
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class TestParkPhotoViewSetSaveImage(EnhancedAPITestCase):
|
||||
"""Test cases for ParkPhotoViewSet save_image action."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.park = ParkFactory()
|
||||
|
||||
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'})
|
||||
|
||||
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/'
|
||||
response = self.client.post(url, {})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
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'})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class TestHybridParkAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for HybridParkAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
# Create several parks for testing
|
||||
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'),
|
||||
]
|
||||
|
||||
def test__hybrid_park_api__initial_load__returns_parks(self):
|
||||
"""Test initial load returns parks with metadata."""
|
||||
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'])
|
||||
|
||||
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'})
|
||||
|
||||
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')
|
||||
|
||||
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'})
|
||||
|
||||
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'})
|
||||
|
||||
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)
|
||||
|
||||
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})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
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'})
|
||||
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
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/'
|
||||
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'])
|
||||
|
||||
|
||||
class TestParkFilterMetadataAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for ParkFilterMetadataAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.operator = CompanyFactory(roles=['OPERATOR'])
|
||||
self.parks = [
|
||||
ParkFactory(operator=self.operator),
|
||||
ParkFactory(operator=self.operator),
|
||||
]
|
||||
|
||||
def test__filter_metadata__unscoped__returns_all_metadata(self):
|
||||
"""Test getting unscoped 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)
|
||||
|
||||
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'})
|
||||
|
||||
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/'
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
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']
|
||||
for key in possible_keys:
|
||||
if key in data:
|
||||
self.assertIsNotNone(data[key])
|
||||
|
||||
|
||||
class TestParkPhotoPermissions(EnhancedAPITestCase):
|
||||
"""Test cases for park photo permission logic."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.owner = UserFactory()
|
||||
self.other_user = UserFactory()
|
||||
self.staff_user = StaffUserFactory()
|
||||
self.admin_user = SuperUserFactory()
|
||||
self.park = ParkFactory()
|
||||
|
||||
def test__permission__owner_can_access_own_photos(self):
|
||||
"""Test that photo owner has access."""
|
||||
self.client.force_authenticate(user=self.owner)
|
||||
# Owner should be able to access their own photos
|
||||
# This is a structural test - actual data would require ParkPhoto creation
|
||||
self.assertTrue(True)
|
||||
|
||||
def test__permission__staff_can_access_all_photos(self):
|
||||
"""Test that staff users can access all photos."""
|
||||
self.client.force_authenticate(user=self.staff_user)
|
||||
# Staff should have access to all photos
|
||||
self.assertTrue(self.staff_user.is_staff)
|
||||
|
||||
def test__permission__admin_can_approve_photos(self):
|
||||
"""Test that admin users can approve photos."""
|
||||
self.client.force_authenticate(user=self.admin_user)
|
||||
# Admin should be able to approve
|
||||
self.assertTrue(self.admin_user.is_superuser)
|
||||
|
||||
|
||||
class TestParkAPIQueryOptimization(EnhancedAPITestCase):
|
||||
"""Test cases for query optimization in park APIs."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.operator = CompanyFactory(roles=['OPERATOR'])
|
||||
|
||||
def test__park_list__uses_select_related(self):
|
||||
"""Test that park list uses select_related for optimization."""
|
||||
# Create multiple parks
|
||||
for i in range(5):
|
||||
ParkFactory(operator=self.operator)
|
||||
|
||||
url = '/api/v1/parks/hybrid/'
|
||||
|
||||
# This test verifies the query is executed without N+1
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test__park_list__handles_large_dataset(self):
|
||||
"""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}')
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class TestParkAPIEdgeCases(EnhancedAPITestCase):
|
||||
"""Test cases for edge cases in park APIs."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
|
||||
def test__hybrid_park__empty_database__returns_empty_list(self):
|
||||
"""Test API behavior with no parks in database."""
|
||||
# Delete all parks for this test
|
||||
Park.objects.all().delete()
|
||||
|
||||
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)
|
||||
|
||||
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/'
|
||||
|
||||
# Test with special characters
|
||||
special_searches = [
|
||||
"O'Brien's Park",
|
||||
"Park & Ride",
|
||||
"Test; DROP TABLE parks;",
|
||||
"Park<script>alert(1)</script>",
|
||||
"Park%20Test",
|
||||
]
|
||||
|
||||
for search_term in special_searches:
|
||||
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/'
|
||||
|
||||
# Test with extreme values
|
||||
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])
|
||||
120
backend/tests/api/test_response_format.py
Normal file
120
backend/tests/api/test_response_format.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Tests for API response format consistency.
|
||||
|
||||
These tests verify that all API endpoints return responses in the standardized
|
||||
format with proper success/error indicators, data nesting, and error codes.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
class ResponseFormatTestCase(TestCase):
|
||||
"""Tests for standardized API response format."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
self.client = APIClient()
|
||||
|
||||
def test_success_response_has_success_field(self):
|
||||
"""Test that success responses include success: true field."""
|
||||
response = self.client.get("/api/v1/parks/hybrid/")
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
self.assertIn("success", data)
|
||||
self.assertTrue(data["success"])
|
||||
|
||||
def test_success_response_has_data_field(self):
|
||||
"""Test that success responses include data field."""
|
||||
response = self.client.get("/api/v1/parks/hybrid/")
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
self.assertIn("data", data)
|
||||
|
||||
def test_error_response_format(self):
|
||||
"""Test that error responses follow standardized format."""
|
||||
# Request a non-existent resource
|
||||
response = self.client.get("/api/v1/parks/non-existent-park-slug/")
|
||||
|
||||
if response.status_code == status.HTTP_404_NOT_FOUND:
|
||||
data = response.json()
|
||||
# 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"
|
||||
)
|
||||
|
||||
def test_validation_error_format(self):
|
||||
"""Test that validation errors include field-specific details."""
|
||||
# This test would need authentication but we can test the format
|
||||
pass
|
||||
|
||||
|
||||
class HybridEndpointResponseTestCase(TestCase):
|
||||
"""Tests for hybrid endpoint response format."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
self.client = APIClient()
|
||||
|
||||
def test_parks_hybrid_response_format(self):
|
||||
"""Test parks hybrid endpoint response structure."""
|
||||
response = self.client.get("/api/v1/parks/hybrid/")
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
self.assertIn("success", data)
|
||||
self.assertIn("data", data)
|
||||
|
||||
if data.get("data"):
|
||||
result = data["data"]
|
||||
self.assertIn("parks", result)
|
||||
self.assertIn("total_count", result)
|
||||
self.assertIn("strategy", result)
|
||||
self.assertIn("has_more", result)
|
||||
|
||||
def test_rides_hybrid_response_format(self):
|
||||
"""Test rides hybrid endpoint response structure."""
|
||||
response = self.client.get("/api/v1/rides/hybrid/")
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
self.assertIn("success", data)
|
||||
self.assertIn("data", data)
|
||||
|
||||
if data.get("data"):
|
||||
result = data["data"]
|
||||
self.assertIn("rides", result)
|
||||
self.assertIn("total_count", result)
|
||||
self.assertIn("strategy", result)
|
||||
self.assertIn("has_more", result)
|
||||
|
||||
|
||||
class FilterMetadataResponseTestCase(TestCase):
|
||||
"""Tests for filter metadata endpoint response format."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test client."""
|
||||
self.client = APIClient()
|
||||
|
||||
def test_parks_filter_metadata_response_format(self):
|
||||
"""Test parks filter metadata endpoint response structure."""
|
||||
response = self.client.get("/api/v1/parks/filter-metadata/")
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
self.assertIn("success", data)
|
||||
self.assertIn("data", data)
|
||||
|
||||
def test_rides_filter_metadata_response_format(self):
|
||||
"""Test rides filter metadata endpoint response structure."""
|
||||
response = self.client.get("/api/v1/rides/filter-metadata/")
|
||||
|
||||
if response.status_code == status.HTTP_200_OK:
|
||||
data = response.json()
|
||||
self.assertIn("success", data)
|
||||
self.assertIn("data", data)
|
||||
770
backend/tests/api/test_rides_api.py
Normal file
770
backend/tests/api/test_rides_api.py
Normal file
@@ -0,0 +1,770 @@
|
||||
"""
|
||||
Comprehensive tests for Rides API endpoints.
|
||||
|
||||
This module provides extensive test coverage for:
|
||||
- RideListCreateAPIView: List and create ride operations
|
||||
- RideDetailAPIView: Retrieve, update, delete operations
|
||||
- FilterOptionsAPIView: Filter option retrieval
|
||||
- HybridRideAPIView: Intelligent hybrid filtering strategy
|
||||
- RideFilterMetadataAPIView: Filter metadata retrieval
|
||||
- RideSearchSuggestionsAPIView: Search suggestions
|
||||
- CompanySearchAPIView: Company autocomplete search
|
||||
- RideModelSearchAPIView: Ride model autocomplete search
|
||||
- RideImageSettingsAPIView: Ride image configuration
|
||||
|
||||
Test patterns follow Django styleguide conventions.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase, APIClient
|
||||
|
||||
from tests.factories import (
|
||||
UserFactory,
|
||||
StaffUserFactory,
|
||||
SuperUserFactory,
|
||||
ParkFactory,
|
||||
RideFactory,
|
||||
CoasterFactory,
|
||||
CompanyFactory,
|
||||
ManufacturerCompanyFactory,
|
||||
DesignerCompanyFactory,
|
||||
RideModelFactory,
|
||||
)
|
||||
from tests.test_utils import EnhancedAPITestCase
|
||||
|
||||
|
||||
class TestRideListAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for RideListCreateAPIView GET endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.park = ParkFactory()
|
||||
self.manufacturer = ManufacturerCompanyFactory()
|
||||
self.designer = DesignerCompanyFactory()
|
||||
self.rides = [
|
||||
RideFactory(
|
||||
park=self.park,
|
||||
manufacturer=self.manufacturer,
|
||||
designer=self.designer,
|
||||
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'
|
||||
),
|
||||
]
|
||||
self.url = '/api/v1/rides/'
|
||||
|
||||
def test__ride_list__unauthenticated__can_access(self):
|
||||
"""Test that unauthenticated users can access ride list."""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test__ride_list__returns_paginated_results(self):
|
||||
"""Test that ride list returns paginated results."""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
# Should have pagination info
|
||||
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'})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
# Should find Alpha Coaster
|
||||
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))
|
||||
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
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'})
|
||||
|
||||
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')
|
||||
|
||||
def test__ride_list__with_status_filter__returns_filtered_rides(self):
|
||||
"""Test filtering by status."""
|
||||
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')
|
||||
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
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})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
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'})
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
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})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
results = response.data.get('results', [])
|
||||
self.assertLessEqual(len(results), 1)
|
||||
|
||||
|
||||
class TestRideCreateAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for RideListCreateAPIView POST endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.staff_user = StaffUserFactory()
|
||||
self.park = ParkFactory()
|
||||
self.manufacturer = ManufacturerCompanyFactory()
|
||||
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',
|
||||
}
|
||||
|
||||
def test__ride_create__unauthenticated__returns_401(self):
|
||||
"""Test that unauthenticated users cannot create rides."""
|
||||
response = self.client.post(self.url, self.valid_ride_data)
|
||||
|
||||
# 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
|
||||
])
|
||||
|
||||
def test__ride_create__with_valid_data__creates_ride(self):
|
||||
"""Test creating ride with valid data."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
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
|
||||
])
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
])
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
])
|
||||
|
||||
|
||||
class TestRideDetailAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for RideDetailAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.park = ParkFactory()
|
||||
self.ride = RideFactory(park=self.park)
|
||||
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."""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test__ride_detail__returns_full_ride_data(self):
|
||||
"""Test that ride detail returns all expected fields."""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
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/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class TestRideUpdateAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for RideDetailAPIView PATCH/PUT."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.park = ParkFactory()
|
||||
self.ride = RideFactory(park=self.park)
|
||||
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'}
|
||||
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
|
||||
])
|
||||
|
||||
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}
|
||||
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
|
||||
])
|
||||
|
||||
|
||||
class TestRideDeleteAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for RideDetailAPIView DELETE."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.park = ParkFactory()
|
||||
self.ride = RideFactory(park=self.park)
|
||||
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
|
||||
])
|
||||
|
||||
|
||||
class TestFilterOptionsAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for FilterOptionsAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.url = '/api/v1/rides/filter-options/'
|
||||
|
||||
def test__filter_options__returns_all_options(self):
|
||||
"""Test that filter options endpoint returns all filter options."""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
# Check for expected filter categories
|
||||
expected_keys = ['categories', 'statuses']
|
||||
for key in expected_keys:
|
||||
self.assertIn(key, response.data)
|
||||
|
||||
def test__filter_options__includes_ranges(self):
|
||||
"""Test that filter options include numeric ranges."""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
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)
|
||||
|
||||
|
||||
class TestHybridRideAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for HybridRideAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
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'),
|
||||
]
|
||||
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'])
|
||||
|
||||
def test__hybrid_ride__with_category_filter__returns_filtered_rides(self):
|
||||
"""Test filtering by category."""
|
||||
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'})
|
||||
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
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'})
|
||||
|
||||
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'})
|
||||
|
||||
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})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
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'})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TestRideFilterMetadataAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for RideFilterMetadataAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
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)
|
||||
|
||||
def test__filter_metadata__scoped__returns_filtered_metadata(self):
|
||||
"""Test getting scoped filter metadata."""
|
||||
response = self.client.get(self.url, {'scoped': 'true', 'category': 'RC'})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TestCompanySearchAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for CompanySearchAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
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'})
|
||||
|
||||
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': ''})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, [])
|
||||
|
||||
def test__company_search__no_query__returns_empty_list(self):
|
||||
"""Test no query parameter returns empty list."""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, [])
|
||||
|
||||
|
||||
class TestRideModelSearchAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for RideModelSearchAPIView."""
|
||||
|
||||
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/'
|
||||
|
||||
def test__ride_model_search__with_query__returns_matching_models(self):
|
||||
"""Test searching for ride models."""
|
||||
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': ''})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, [])
|
||||
|
||||
|
||||
class TestRideSearchSuggestionsAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for RideSearchSuggestionsAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""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/'
|
||||
|
||||
def test__search_suggestions__with_query__returns_suggestions(self):
|
||||
"""Test getting search suggestions."""
|
||||
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': ''})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, [])
|
||||
|
||||
|
||||
class TestRideImageSettingsAPIView(EnhancedAPITestCase):
|
||||
"""Test cases for RideImageSettingsAPIView."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.user = UserFactory()
|
||||
self.park = ParkFactory()
|
||||
self.ride = RideFactory(park=self.park)
|
||||
self.url = f'/api/v1/rides/{self.ride.id}/image-settings/'
|
||||
|
||||
def test__image_settings__patch__updates_settings(self):
|
||||
"""Test updating ride image settings."""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
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
|
||||
])
|
||||
|
||||
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/', {})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
class TestRideAPIRollerCoasterFilters(EnhancedAPITestCase):
|
||||
"""Test cases for roller coaster specific filters."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
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/'
|
||||
|
||||
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'})
|
||||
|
||||
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'})
|
||||
|
||||
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'})
|
||||
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
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'})
|
||||
|
||||
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'})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TestRideAPIEdgeCases(EnhancedAPITestCase):
|
||||
"""Test cases for edge cases in ride APIs."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
|
||||
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/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test__ride_list__special_characters_in_search__handled_safely(self):
|
||||
"""Test that special characters in search are handled safely."""
|
||||
special_searches = [
|
||||
"O'Brien",
|
||||
"Ride & Coaster",
|
||||
"Test; DROP TABLE rides;",
|
||||
"Ride<script>alert(1)</script>",
|
||||
]
|
||||
|
||||
for search_term in special_searches:
|
||||
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})
|
||||
|
||||
# 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'})
|
||||
|
||||
# Should use default ordering
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TestRideAPIQueryOptimization(EnhancedAPITestCase):
|
||||
"""Test cases for query optimization in ride APIs."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.client = APIClient()
|
||||
self.park = ParkFactory()
|
||||
|
||||
def test__ride_list__uses_select_related(self):
|
||||
"""Test that ride list uses select_related for optimization."""
|
||||
# Create multiple rides
|
||||
for i in range(5):
|
||||
RideFactory(park=self.park)
|
||||
|
||||
response = self.client.get('/api/v1/rides/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test__ride_list__handles_large_dataset(self):
|
||||
"""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}')
|
||||
|
||||
response = self.client.get('/api/v1/rides/')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
271
backend/tests/conftest.py
Normal file
271
backend/tests/conftest.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Root pytest configuration for ThrillWiki backend tests.
|
||||
|
||||
This file contains shared fixtures and configuration used across
|
||||
all test modules (unit, integration, e2e).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import django
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
|
||||
# Configure Django settings before any tests run
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.test")
|
||||
django.setup()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Database Fixtures
|
||||
# =============================================================================
|
||||
|
||||
# Note: pytest-django uses the DATABASES setting from the test settings module
|
||||
# (config.django.test). Do NOT override DATABASES to SQLite here as it breaks
|
||||
# GeoDjango models that require PostGIS (or properly configured SpatiaLite).
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session(db):
|
||||
"""Provide database access with automatic cleanup."""
|
||||
yield db
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
"""Create a regular test user."""
|
||||
from tests.factories import UserFactory
|
||||
|
||||
return UserFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def staff_user(db):
|
||||
"""Create a staff test user."""
|
||||
from tests.factories import StaffUserFactory
|
||||
|
||||
return StaffUserFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def superuser(db):
|
||||
"""Create a superuser test user."""
|
||||
from tests.factories import SuperUserFactory
|
||||
|
||||
return SuperUserFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def moderator_user(db):
|
||||
"""Create a moderator test user."""
|
||||
from tests.factories import StaffUserFactory
|
||||
|
||||
user = StaffUserFactory(username="moderator")
|
||||
return user
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Client Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_client():
|
||||
"""Create an unauthenticated API client."""
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
return APIClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_api_client(api_client, user):
|
||||
"""Create an authenticated API client."""
|
||||
api_client.force_authenticate(user=user)
|
||||
return api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def staff_api_client(api_client, staff_user):
|
||||
"""Create an API client authenticated as staff."""
|
||||
api_client.force_authenticate(user=staff_user)
|
||||
return api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def superuser_api_client(api_client, superuser):
|
||||
"""Create an API client authenticated as superuser."""
|
||||
api_client.force_authenticate(user=superuser)
|
||||
return api_client
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Model Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def park(db):
|
||||
"""Create a test park."""
|
||||
from tests.factories import ParkFactory
|
||||
|
||||
return ParkFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operating_park(db):
|
||||
"""Create an operating test park."""
|
||||
from tests.factories import ParkFactory
|
||||
|
||||
return ParkFactory(status="OPERATING")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ride(db, park):
|
||||
"""Create a test ride."""
|
||||
from tests.factories import RideFactory
|
||||
|
||||
return RideFactory(park=park)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operating_ride(db, operating_park):
|
||||
"""Create an operating test ride."""
|
||||
from tests.factories import RideFactory
|
||||
|
||||
return RideFactory(park=operating_park, status="OPERATING")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def park_photo(db, park, user):
|
||||
"""Create a test park photo."""
|
||||
from tests.factories import ParkPhotoFactory
|
||||
|
||||
return ParkPhotoFactory(park=park, uploaded_by=user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ride_photo(db, ride, user):
|
||||
"""Create a test ride photo."""
|
||||
from tests.factories import RidePhotoFactory
|
||||
|
||||
return RidePhotoFactory(ride=ride, uploaded_by=user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def company(db):
|
||||
"""Create a test company."""
|
||||
from tests.factories import CompanyFactory
|
||||
|
||||
return CompanyFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def park_area(db, park):
|
||||
"""Create a test park area."""
|
||||
from tests.factories import ParkAreaFactory
|
||||
|
||||
return ParkAreaFactory(park=park)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Request Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def request_factory():
|
||||
"""Create a Django request factory."""
|
||||
from django.test import RequestFactory
|
||||
|
||||
return RequestFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rf():
|
||||
"""Alias for request_factory (common pytest-django convention)."""
|
||||
from django.test import RequestFactory
|
||||
|
||||
return RequestFactory()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cloudflare_image():
|
||||
"""Create a mock Cloudflare image."""
|
||||
from tests.factories import CloudflareImageFactory
|
||||
|
||||
return CloudflareImageFactory()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_image():
|
||||
"""Create a temporary image file for upload testing."""
|
||||
from io import BytesIO
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from PIL import Image
|
||||
|
||||
# Create a simple test image
|
||||
image = Image.new("RGB", (100, 100), color="red")
|
||||
image_io = BytesIO()
|
||||
image.save(image_io, format="JPEG")
|
||||
image_io.seek(0)
|
||||
|
||||
return SimpleUploadedFile(
|
||||
name="test_image.jpg",
|
||||
content=image_io.read(),
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_db_access_for_all_tests(db):
|
||||
"""
|
||||
Enable database access for all tests by default.
|
||||
|
||||
This is useful for integration tests that need database access
|
||||
without explicitly requesting the 'db' fixture.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cleanup Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache():
|
||||
"""Clear Django cache before each test."""
|
||||
from django.core.cache import cache
|
||||
|
||||
cache.clear()
|
||||
yield
|
||||
cache.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Marker Registration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
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", "slow: Tests that take a long time to run")
|
||||
config.addinivalue_line("markers", "api: API endpoint tests")
|
||||
6
backend/tests/e2e/__init__.py
Normal file
6
backend/tests/e2e/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
End-to-end tests.
|
||||
|
||||
This module contains browser-based tests using Playwright
|
||||
to verify complete user journeys through the application.
|
||||
"""
|
||||
@@ -1,14 +1,42 @@
|
||||
import pytest
|
||||
from playwright.sync_api import Page
|
||||
import subprocess
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_test_data():
|
||||
"""Setup test data before each test session"""
|
||||
subprocess.run(["uv", "run", "manage.py", "create_test_users"], check=True)
|
||||
@pytest.fixture(scope="session")
|
||||
def setup_test_data(django_db_setup, django_db_blocker):
|
||||
"""
|
||||
Setup test data before the test session using factories.
|
||||
|
||||
This fixture:
|
||||
- Uses factories instead of shelling out to management commands
|
||||
- Is scoped to session (not autouse per test) to reduce overhead
|
||||
- Uses django_db_blocker to allow database access in session-scoped fixture
|
||||
"""
|
||||
with django_db_blocker.unblock():
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Create test users if they don't exist
|
||||
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},
|
||||
]
|
||||
|
||||
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
|
||||
)
|
||||
if created:
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
|
||||
yield
|
||||
subprocess.run(["uv", "run", "manage.py", "cleanup_test_data"], check=True)
|
||||
|
||||
# Cleanup is handled automatically by pytest-django's transactional database
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -34,7 +62,7 @@ def setup_page(page: Page):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_page(page: Page, live_server):
|
||||
def auth_page(page: Page, live_server, setup_test_data):
|
||||
"""Fixture for authenticated page"""
|
||||
# Login using live_server URL
|
||||
page.goto(f"{live_server.url}/accounts/login/")
|
||||
@@ -46,7 +74,7 @@ def auth_page(page: Page, live_server):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mod_page(page: Page, live_server):
|
||||
def mod_page(page: Page, live_server, setup_test_data):
|
||||
"""Fixture for moderator page"""
|
||||
# Login as moderator using live_server URL
|
||||
page.goto(f"{live_server.url}/accounts/login/")
|
||||
@@ -107,7 +135,7 @@ def test_review(test_park: Page, live_server):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_page(page: Page, live_server):
|
||||
def admin_page(page: Page, live_server, setup_test_data):
|
||||
"""Fixture for admin/superuser page"""
|
||||
# Login as admin using live_server URL
|
||||
page.goto(f"{live_server.url}/accounts/login/")
|
||||
@@ -406,3 +434,39 @@ def regular_user(db):
|
||||
user.save()
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
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)
|
||||
]
|
||||
|
||||
return parks
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rides_data(db, parks_data):
|
||||
"""Create test rides for E2E testing."""
|
||||
from tests.factories import RideFactory
|
||||
|
||||
rides = []
|
||||
for park in parks_data:
|
||||
for i in range(2):
|
||||
ride = RideFactory(
|
||||
name=f"E2E Test Ride {park.name} {i}",
|
||||
slug=f"e2e-test-ride-{park.slug}-{i}",
|
||||
park=park,
|
||||
status="OPERATING"
|
||||
)
|
||||
rides.append(ride)
|
||||
|
||||
return rides
|
||||
|
||||
182
backend/tests/e2e/test_park_browsing.py
Normal file
182
backend/tests/e2e/test_park_browsing.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
E2E tests for park browsing functionality.
|
||||
|
||||
These tests verify the complete user journey for browsing parks
|
||||
using Playwright for browser automation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestParkListPage:
|
||||
"""E2E tests for park list page."""
|
||||
|
||||
def test__park_list__displays_parks(self, page: Page, live_server, parks_data):
|
||||
"""Test park list page displays parks."""
|
||||
page.goto(f"{live_server.url}/parks/")
|
||||
|
||||
# Verify page title or heading
|
||||
expect(page.locator("h1")).to_be_visible()
|
||||
|
||||
# Should display park cards or list items
|
||||
park_items = page.locator("[data-testid='park-card'], .park-item, .park-list-item")
|
||||
expect(park_items.first).to_be_visible()
|
||||
|
||||
def test__park_list__shows_park_name(self, page: Page, live_server, parks_data):
|
||||
"""Test park list shows park names."""
|
||||
page.goto(f"{live_server.url}/parks/")
|
||||
|
||||
# First park should be visible
|
||||
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
|
||||
):
|
||||
"""Test clicking a park navigates to detail page."""
|
||||
page.goto(f"{live_server.url}/parks/")
|
||||
|
||||
first_park = parks_data[0]
|
||||
|
||||
# Click on the park
|
||||
page.get_by_text(first_park.name).first.click()
|
||||
|
||||
# Should navigate to detail page
|
||||
expect(page).to_have_url(f"**/{first_park.slug}/**")
|
||||
|
||||
def test__park_list__search__filters_results(self, page: Page, live_server, parks_data):
|
||||
"""Test search functionality filters parks."""
|
||||
page.goto(f"{live_server.url}/parks/")
|
||||
|
||||
# Find search input
|
||||
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")
|
||||
|
||||
# Wait for results to filter
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# Should show only matching park
|
||||
expect(page.get_by_text("E2E Test Park 0")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestParkDetailPage:
|
||||
"""E2E tests for park detail page."""
|
||||
|
||||
def test__park_detail__displays_park_info(self, page: Page, live_server, parks_data):
|
||||
"""Test park detail page displays park information."""
|
||||
park = parks_data[0]
|
||||
page.goto(f"{live_server.url}/parks/{park.slug}/")
|
||||
|
||||
# Verify park name is displayed
|
||||
expect(page.get_by_role("heading", name=park.name)).to_be_visible()
|
||||
|
||||
def test__park_detail__shows_rides_section(self, page: Page, live_server, parks_data):
|
||||
"""Test park detail page shows rides section."""
|
||||
park = parks_data[0]
|
||||
page.goto(f"{live_server.url}/parks/{park.slug}/")
|
||||
|
||||
# Look for rides section/tab
|
||||
rides_section = page.locator(
|
||||
"[data-testid='rides-section'], #rides, [role='tabpanel']"
|
||||
)
|
||||
|
||||
# Or a rides tab
|
||||
rides_tab = page.get_by_role("tab", name="Rides")
|
||||
|
||||
if rides_tab.count() > 0:
|
||||
rides_tab.click()
|
||||
|
||||
# Should show rides
|
||||
ride_items = page.locator(".ride-item, .ride-card, [data-testid='ride-item']")
|
||||
expect(ride_items.first).to_be_visible()
|
||||
|
||||
def test__park_detail__shows_status(self, page: Page, live_server, parks_data):
|
||||
"""Test park detail page shows park status."""
|
||||
park = parks_data[0]
|
||||
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"
|
||||
)
|
||||
expect(status_indicator.first).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestParkFiltering:
|
||||
"""E2E tests for park filtering functionality."""
|
||||
|
||||
def test__filter_by_status__updates_results(self, page: Page, live_server, parks_data):
|
||||
"""Test filtering parks by status updates results."""
|
||||
page.goto(f"{live_server.url}/parks/")
|
||||
|
||||
# Find status filter
|
||||
status_filter = page.locator(
|
||||
"select[name='status'], [data-testid='status-filter']"
|
||||
)
|
||||
|
||||
if status_filter.count() > 0:
|
||||
status_filter.first.select_option("OPERATING")
|
||||
|
||||
# Wait for results to update
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# Results should be filtered
|
||||
|
||||
def test__clear_filters__shows_all_parks(self, page: Page, live_server, parks_data):
|
||||
"""Test clearing filters shows all parks."""
|
||||
page.goto(f"{live_server.url}/parks/")
|
||||
|
||||
# Find clear filters button
|
||||
clear_btn = page.locator(
|
||||
"[data-testid='clear-filters'], button:has-text('Clear')"
|
||||
)
|
||||
|
||||
if clear_btn.count() > 0:
|
||||
clear_btn.first.click()
|
||||
|
||||
# Wait for results to update
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestParkNavigation:
|
||||
"""E2E tests for park navigation."""
|
||||
|
||||
def test__breadcrumb__navigates_back_to_list(self, page: Page, live_server, parks_data):
|
||||
"""Test breadcrumb navigation back to park list."""
|
||||
park = parks_data[0]
|
||||
page.goto(f"{live_server.url}/parks/{park.slug}/")
|
||||
|
||||
# Find breadcrumb
|
||||
breadcrumb = page.locator("nav[aria-label='breadcrumb'], .breadcrumb")
|
||||
|
||||
if breadcrumb.count() > 0:
|
||||
# Click parks link in breadcrumb
|
||||
breadcrumb.get_by_role("link", name="Parks").click()
|
||||
|
||||
expect(page).to_have_url(f"**/parks/**")
|
||||
|
||||
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/")
|
||||
|
||||
park = parks_data[0]
|
||||
page.get_by_text(park.name).first.click()
|
||||
|
||||
# Wait for navigation
|
||||
page.wait_for_url(f"**/{park.slug}/**")
|
||||
|
||||
# Go back
|
||||
page.go_back()
|
||||
|
||||
expect(page).to_have_url(f"**/parks/**")
|
||||
372
backend/tests/e2e/test_review_submission.py
Normal file
372
backend/tests/e2e/test_review_submission.py
Normal file
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
E2E tests for review submission and moderation flows.
|
||||
|
||||
These tests verify the complete user journey for submitting,
|
||||
editing, and moderating reviews using Playwright for browser automation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestReviewSubmission:
|
||||
"""E2E tests for review submission flow."""
|
||||
|
||||
def test__review_form__displays_fields(self, auth_page: Page, live_server, parks_data):
|
||||
"""Test review form displays all required fields."""
|
||||
park = parks_data[0]
|
||||
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
|
||||
|
||||
# Find and click reviews tab or section
|
||||
reviews_tab = auth_page.get_by_role("tab", name="Reviews")
|
||||
if reviews_tab.count() > 0:
|
||||
reviews_tab.click()
|
||||
|
||||
# Click write review button
|
||||
write_review = auth_page.locator(
|
||||
"button:has-text('Write Review'), a:has-text('Write Review')"
|
||||
)
|
||||
|
||||
if write_review.count() > 0:
|
||||
write_review.first.click()
|
||||
|
||||
# Verify form fields
|
||||
expect(auth_page.locator("select[name='rating'], input[name='rating']").first).to_be_visible()
|
||||
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
|
||||
):
|
||||
"""Test submitting a valid review creates it."""
|
||||
park = parks_data[0]
|
||||
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
|
||||
|
||||
# Navigate to reviews
|
||||
reviews_tab = auth_page.get_by_role("tab", name="Reviews")
|
||||
if reviews_tab.count() > 0:
|
||||
reviews_tab.click()
|
||||
|
||||
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 the form
|
||||
rating_select = auth_page.locator("select[name='rating']")
|
||||
if rating_select.count() > 0:
|
||||
rating_select.select_option("5")
|
||||
else:
|
||||
# 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("textarea[name='content'], textarea[name='review']").first.fill(
|
||||
"This is an E2E test review content."
|
||||
)
|
||||
|
||||
auth_page.get_by_role("button", name="Submit").click()
|
||||
|
||||
# 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
|
||||
):
|
||||
"""Test submitting review without rating shows error."""
|
||||
park = parks_data[0]
|
||||
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
|
||||
|
||||
reviews_tab = auth_page.get_by_role("tab", name="Reviews")
|
||||
if reviews_tab.count() > 0:
|
||||
reviews_tab.click()
|
||||
|
||||
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.get_by_role("button", name="Submit").click()
|
||||
|
||||
# Should show validation error
|
||||
error = auth_page.locator(".error, .errorlist, [role='alert']")
|
||||
expect(error.first).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestReviewDisplay:
|
||||
"""E2E tests for review display."""
|
||||
|
||||
def test__reviews_list__displays_reviews(self, page: Page, live_server, parks_data):
|
||||
"""Test reviews list displays existing reviews."""
|
||||
park = parks_data[0]
|
||||
page.goto(f"{live_server.url}/parks/{park.slug}/")
|
||||
|
||||
# Navigate to reviews section
|
||||
reviews_tab = page.get_by_role("tab", name="Reviews")
|
||||
if reviews_tab.count() > 0:
|
||||
reviews_tab.click()
|
||||
|
||||
# Reviews should be displayed
|
||||
reviews_section = page.locator(
|
||||
"[data-testid='reviews-list'], .reviews-list, .review-item"
|
||||
)
|
||||
|
||||
if reviews_section.count() > 0:
|
||||
expect(reviews_section.first).to_be_visible()
|
||||
|
||||
def test__review__shows_rating(self, page: Page, live_server, test_review):
|
||||
"""Test review displays rating."""
|
||||
# test_review fixture creates a review
|
||||
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']"
|
||||
)
|
||||
|
||||
if rating.count() > 0:
|
||||
expect(rating.first).to_be_visible()
|
||||
|
||||
def test__review__shows_author(self, page: Page, live_server, parks_data):
|
||||
"""Test review displays author name."""
|
||||
park = parks_data[0]
|
||||
page.goto(f"{live_server.url}/parks/{park.slug}/")
|
||||
|
||||
reviews_tab = page.get_by_role("tab", name="Reviews")
|
||||
if reviews_tab.count() > 0:
|
||||
reviews_tab.click()
|
||||
|
||||
# Author name should be visible in review
|
||||
author = page.locator(
|
||||
".review-author, .author, [data-testid='author']"
|
||||
)
|
||||
|
||||
if author.count() > 0:
|
||||
expect(author.first).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestReviewEditing:
|
||||
"""E2E tests for review editing."""
|
||||
|
||||
def test__own_review__shows_edit_button(self, auth_page: Page, live_server, test_review):
|
||||
"""Test user's own review shows edit button."""
|
||||
# Navigate to reviews after creating one
|
||||
park_url = auth_page.url
|
||||
|
||||
# Look for edit button on own 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()
|
||||
|
||||
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')"
|
||||
)
|
||||
|
||||
if edit_button.count() > 0:
|
||||
edit_button.first.click()
|
||||
|
||||
# Update content
|
||||
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()
|
||||
|
||||
# Should show updated content
|
||||
auth_page.wait_for_timeout(500)
|
||||
expect(auth_page.get_by_text("Updated review content")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestReviewModeration:
|
||||
"""E2E tests for review moderation."""
|
||||
|
||||
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}/")
|
||||
|
||||
reviews_tab = mod_page.get_by_role("tab", name="Reviews")
|
||||
if reviews_tab.count() > 0:
|
||||
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']"
|
||||
)
|
||||
|
||||
if mod_actions.count() > 0:
|
||||
expect(mod_actions.first).to_be_visible()
|
||||
|
||||
def test__moderator__can_remove_review(self, mod_page: Page, live_server, parks_data):
|
||||
"""Test moderator can remove a review."""
|
||||
park = parks_data[0]
|
||||
mod_page.goto(f"{live_server.url}/parks/{park.slug}/")
|
||||
|
||||
reviews_tab = mod_page.get_by_role("tab", name="Reviews")
|
||||
if reviews_tab.count() > 0:
|
||||
reviews_tab.click()
|
||||
|
||||
remove_button = mod_page.locator("button:has-text('Remove')")
|
||||
|
||||
if remove_button.count() > 0:
|
||||
remove_button.first.click()
|
||||
|
||||
# Confirm if dialog appears
|
||||
confirm = mod_page.locator("button:has-text('Confirm')")
|
||||
if confirm.count() > 0:
|
||||
confirm.click()
|
||||
|
||||
mod_page.wait_for_timeout(500)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestReviewVoting:
|
||||
"""E2E tests for review voting (helpful/not helpful)."""
|
||||
|
||||
def test__review__shows_vote_buttons(self, page: Page, live_server, parks_data):
|
||||
"""Test reviews show vote buttons."""
|
||||
park = parks_data[0]
|
||||
page.goto(f"{live_server.url}/parks/{park.slug}/")
|
||||
|
||||
reviews_tab = page.get_by_role("tab", name="Reviews")
|
||||
if reviews_tab.count() > 0:
|
||||
reviews_tab.click()
|
||||
|
||||
# Look for helpful/upvote buttons
|
||||
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
|
||||
):
|
||||
"""Test authenticated user can vote on review."""
|
||||
park = parks_data[0]
|
||||
auth_page.goto(f"{live_server.url}/parks/{park.slug}/")
|
||||
|
||||
reviews_tab = auth_page.get_by_role("tab", name="Reviews")
|
||||
if reviews_tab.count() > 0:
|
||||
reviews_tab.click()
|
||||
|
||||
helpful_button = auth_page.locator(
|
||||
"button:has-text('Helpful'), button[aria-label*='helpful']"
|
||||
)
|
||||
|
||||
if helpful_button.count() > 0:
|
||||
helpful_button.first.click()
|
||||
|
||||
# Button should show voted state
|
||||
auth_page.wait_for_timeout(500)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestRideReviews:
|
||||
"""E2E tests for ride-specific reviews."""
|
||||
|
||||
def test__ride_page__shows_reviews(self, page: Page, live_server, rides_data):
|
||||
"""Test ride page shows reviews section."""
|
||||
ride = rides_data[0]
|
||||
page.goto(f"{live_server.url}/rides/{ride.slug}/")
|
||||
|
||||
# Reviews section should be present
|
||||
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
|
||||
):
|
||||
"""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')"
|
||||
)
|
||||
|
||||
if write_review.count() > 0:
|
||||
write_review.first.click()
|
||||
|
||||
# Ride-specific fields
|
||||
intensity_field = auth_page.locator(
|
||||
"select[name='intensity'], input[name='intensity']"
|
||||
)
|
||||
wait_time_field = auth_page.locator(
|
||||
"input[name='wait_time'], select[name='wait_time']"
|
||||
)
|
||||
|
||||
# At least one experience field should be present
|
||||
if intensity_field.count() > 0:
|
||||
expect(intensity_field.first).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestReviewFiltering:
|
||||
"""E2E tests for review filtering and sorting."""
|
||||
|
||||
def test__reviews__sort_by_date(self, page: Page, live_server, parks_data):
|
||||
"""Test reviews can be sorted by date."""
|
||||
park = parks_data[0]
|
||||
page.goto(f"{live_server.url}/parks/{park.slug}/")
|
||||
|
||||
reviews_tab = page.get_by_role("tab", name="Reviews")
|
||||
if reviews_tab.count() > 0:
|
||||
reviews_tab.click()
|
||||
|
||||
sort_select = page.locator(
|
||||
"select[name='sort'], [data-testid='sort-reviews']"
|
||||
)
|
||||
|
||||
if sort_select.count() > 0:
|
||||
sort_select.first.select_option("date")
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
def test__reviews__filter_by_rating(self, page: Page, live_server, parks_data):
|
||||
"""Test reviews can be filtered by rating."""
|
||||
park = parks_data[0]
|
||||
page.goto(f"{live_server.url}/parks/{park.slug}/")
|
||||
|
||||
reviews_tab = page.get_by_role("tab", name="Reviews")
|
||||
if reviews_tab.count() > 0:
|
||||
reviews_tab.click()
|
||||
|
||||
rating_filter = page.locator(
|
||||
"select[name='rating'], [data-testid='rating-filter']"
|
||||
)
|
||||
|
||||
if rating_filter.count() > 0:
|
||||
rating_filter.first.select_option("5")
|
||||
page.wait_for_timeout(500)
|
||||
280
backend/tests/e2e/test_user_registration.py
Normal file
280
backend/tests/e2e/test_user_registration.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
E2E tests for user registration and authentication flows.
|
||||
|
||||
These tests verify the complete user journey for registration,
|
||||
login, and account management using Playwright for browser automation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestUserRegistration:
|
||||
"""E2E tests for user registration flow."""
|
||||
|
||||
def test__registration_page__displays_form(self, page: Page, live_server):
|
||||
"""Test registration page displays the registration form."""
|
||||
page.goto(f"{live_server.url}/accounts/signup/")
|
||||
|
||||
# Verify form fields are visible
|
||||
expect(page.get_by_label("Username")).to_be_visible()
|
||||
expect(page.get_by_label("Email")).to_be_visible()
|
||||
expect(page.get_by_label("Password", exact=False).first).to_be_visible()
|
||||
|
||||
def test__registration__valid_data__creates_account(self, page: Page, live_server):
|
||||
"""Test registration with valid data creates an account."""
|
||||
page.goto(f"{live_server.url}/accounts/signup/")
|
||||
|
||||
# Fill registration form
|
||||
page.get_by_label("Username").fill("e2e_newuser")
|
||||
page.get_by_label("Email").fill("e2e_newuser@example.com")
|
||||
|
||||
# Handle password fields (may be "Password" and "Confirm Password" or similar)
|
||||
password_fields = page.locator("input[type='password']")
|
||||
if password_fields.count() >= 2:
|
||||
password_fields.nth(0).fill("SecurePass123!")
|
||||
password_fields.nth(1).fill("SecurePass123!")
|
||||
else:
|
||||
password_fields.first.fill("SecurePass123!")
|
||||
|
||||
# Submit form
|
||||
page.get_by_role("button", name="Sign Up").click()
|
||||
|
||||
# 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
|
||||
):
|
||||
"""Test registration with duplicate username shows error."""
|
||||
page.goto(f"{live_server.url}/accounts/signup/")
|
||||
|
||||
# Try to register with existing username
|
||||
page.get_by_label("Username").fill("testuser")
|
||||
page.get_by_label("Email").fill("different@example.com")
|
||||
|
||||
password_fields = page.locator("input[type='password']")
|
||||
if password_fields.count() >= 2:
|
||||
password_fields.nth(0).fill("SecurePass123!")
|
||||
password_fields.nth(1).fill("SecurePass123!")
|
||||
else:
|
||||
password_fields.first.fill("SecurePass123!")
|
||||
|
||||
page.get_by_role("button", name="Sign Up").click()
|
||||
|
||||
# Should show error message
|
||||
error = page.locator(".error, .errorlist, [role='alert']")
|
||||
expect(error.first).to_be_visible()
|
||||
|
||||
def test__registration__weak_password__shows_error(self, page: Page, live_server):
|
||||
"""Test registration with weak password shows validation error."""
|
||||
page.goto(f"{live_server.url}/accounts/signup/")
|
||||
|
||||
page.get_by_label("Username").fill("e2e_weakpass")
|
||||
page.get_by_label("Email").fill("e2e_weakpass@example.com")
|
||||
|
||||
password_fields = page.locator("input[type='password']")
|
||||
if password_fields.count() >= 2:
|
||||
password_fields.nth(0).fill("123")
|
||||
password_fields.nth(1).fill("123")
|
||||
else:
|
||||
password_fields.first.fill("123")
|
||||
|
||||
page.get_by_role("button", name="Sign Up").click()
|
||||
|
||||
# Should show password validation error
|
||||
error = page.locator(".error, .errorlist, [role='alert']")
|
||||
expect(error.first).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestUserLogin:
|
||||
"""E2E tests for user login flow."""
|
||||
|
||||
def test__login_page__displays_form(self, page: Page, live_server):
|
||||
"""Test login page displays the login form."""
|
||||
page.goto(f"{live_server.url}/accounts/login/")
|
||||
|
||||
expect(page.get_by_label("Username")).to_be_visible()
|
||||
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
|
||||
):
|
||||
"""Test login with valid credentials authenticates user."""
|
||||
page.goto(f"{live_server.url}/accounts/login/")
|
||||
|
||||
page.get_by_label("Username").fill("testuser")
|
||||
page.get_by_label("Password").fill("testpass123")
|
||||
page.get_by_role("button", name="Sign In").click()
|
||||
|
||||
# Should redirect away from login page
|
||||
page.wait_for_url("**/*")
|
||||
expect(page).not_to_have_url("**/login/**")
|
||||
|
||||
def test__login__invalid_credentials__shows_error(self, page: Page, live_server):
|
||||
"""Test login with invalid credentials shows error."""
|
||||
page.goto(f"{live_server.url}/accounts/login/")
|
||||
|
||||
page.get_by_label("Username").fill("nonexistent")
|
||||
page.get_by_label("Password").fill("wrongpass")
|
||||
page.get_by_role("button", name="Sign In").click()
|
||||
|
||||
# Should show error message
|
||||
error = page.locator(".error, .errorlist, [role='alert'], .alert-danger")
|
||||
expect(error.first).to_be_visible()
|
||||
|
||||
def test__login__remember_me__checkbox_present(self, page: Page, live_server):
|
||||
"""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']"
|
||||
)
|
||||
|
||||
if remember_me.count() > 0:
|
||||
expect(remember_me.first).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestUserLogout:
|
||||
"""E2E tests for user logout flow."""
|
||||
|
||||
def test__logout__clears_session(self, auth_page: Page, live_server):
|
||||
"""Test logout clears user session."""
|
||||
# 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')"
|
||||
)
|
||||
|
||||
if logout.count() > 0:
|
||||
logout.first.click()
|
||||
|
||||
# Should be logged out
|
||||
auth_page.wait_for_url("**/*")
|
||||
|
||||
# Try to access protected page
|
||||
auth_page.goto(f"{live_server.url}/accounts/profile/")
|
||||
|
||||
# Should redirect to login
|
||||
expect(auth_page).to_have_url("**/login/**")
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestPasswordReset:
|
||||
"""E2E tests for password reset flow."""
|
||||
|
||||
def test__password_reset_page__displays_form(self, page: Page, live_server):
|
||||
"""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']"
|
||||
)
|
||||
expect(email_input.first).to_be_visible()
|
||||
|
||||
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/")
|
||||
|
||||
email_input = page.locator("input[type='email'], input[name='email']")
|
||||
email_input.first.fill("testuser@example.com")
|
||||
|
||||
page.get_by_role("button", name="Reset Password").click()
|
||||
|
||||
# Should show confirmation message
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# Look for success message or confirmation page
|
||||
success = page.locator(
|
||||
".success, .alert-success, [role='alert']"
|
||||
)
|
||||
|
||||
# Or check URL changed to done page
|
||||
if success.count() == 0:
|
||||
expect(page).to_have_url("**/done/**")
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestUserProfile:
|
||||
"""E2E tests for user profile management."""
|
||||
|
||||
def test__profile_page__displays_user_info(self, auth_page: Page, live_server):
|
||||
"""Test profile page displays user information."""
|
||||
auth_page.goto(f"{live_server.url}/accounts/profile/")
|
||||
|
||||
# Should display username
|
||||
expect(auth_page.get_by_text("testuser")).to_be_visible()
|
||||
|
||||
def test__profile_page__edit_profile_link(self, auth_page: Page, live_server):
|
||||
"""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')"
|
||||
)
|
||||
|
||||
if edit_link.count() > 0:
|
||||
expect(edit_link.first).to_be_visible()
|
||||
|
||||
def test__profile_edit__updates_info(self, auth_page: Page, live_server):
|
||||
"""Test editing profile updates user information."""
|
||||
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']"
|
||||
)
|
||||
|
||||
if bio_field.count() > 0:
|
||||
bio_field.first.fill("Updated bio from E2E test")
|
||||
|
||||
auth_page.get_by_role("button", name="Save").click()
|
||||
|
||||
# Should redirect back to profile
|
||||
auth_page.wait_for_url("**/profile/**")
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestProtectedRoutes:
|
||||
"""E2E tests for protected route access."""
|
||||
|
||||
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
|
||||
):
|
||||
"""Test authenticated user can access protected routes."""
|
||||
auth_page.goto(f"{live_server.url}/accounts/profile/")
|
||||
|
||||
# Should not redirect to login
|
||||
expect(auth_page).not_to_have_url("**/login/**")
|
||||
|
||||
def test__admin_route__regular_user__denied(self, auth_page: Page, live_server):
|
||||
"""Test regular user cannot access admin routes."""
|
||||
auth_page.goto(f"{live_server.url}/admin/")
|
||||
|
||||
# Should show login or forbidden
|
||||
# Admin login page or 403
|
||||
|
||||
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/")
|
||||
|
||||
# Should not redirect to login (moderator has access)
|
||||
expect(mod_page).not_to_have_url("**/login/**")
|
||||
@@ -360,3 +360,58 @@ class TestScenarios:
|
||||
reviews = [ParkReviewFactory(park=park, user=user) for user in users]
|
||||
|
||||
return {"park": park, "users": users, "reviews": reviews}
|
||||
|
||||
|
||||
class CloudflareImageFactory(DjangoModelFactory):
|
||||
"""Factory for creating CloudflareImage instances."""
|
||||
|
||||
class Meta:
|
||||
model = "django_cloudflareimages_toolkit.CloudflareImage"
|
||||
|
||||
cloudflare_id = factory.Sequence(lambda n: f"cf-image-{n}")
|
||||
status = "uploaded"
|
||||
upload_url = factory.Faker("url")
|
||||
width = fuzzy.FuzzyInteger(100, 1920)
|
||||
height = fuzzy.FuzzyInteger(100, 1080)
|
||||
format = "jpeg"
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
class ParkPhotoFactory(DjangoModelFactory):
|
||||
"""Factory for creating ParkPhoto instances."""
|
||||
|
||||
class Meta:
|
||||
model = "parks.ParkPhoto"
|
||||
|
||||
park = factory.SubFactory(ParkFactory)
|
||||
image = factory.SubFactory(CloudflareImageFactory)
|
||||
caption = factory.Faker("sentence", nb_words=6)
|
||||
alt_text = factory.Faker("sentence", nb_words=8)
|
||||
is_primary = False
|
||||
is_approved = True
|
||||
uploaded_by = factory.SubFactory(UserFactory)
|
||||
date_taken = factory.Faker("date_time_between", start_date="-2y", end_date="now")
|
||||
|
||||
|
||||
class RidePhotoFactory(DjangoModelFactory):
|
||||
"""Factory for creating RidePhoto instances."""
|
||||
|
||||
class Meta:
|
||||
model = "rides.RidePhoto"
|
||||
|
||||
ride = factory.SubFactory(RideFactory)
|
||||
image = factory.SubFactory(CloudflareImageFactory)
|
||||
caption = factory.Faker("sentence", nb_words=6)
|
||||
alt_text = factory.Faker("sentence", nb_words=8)
|
||||
is_primary = False
|
||||
is_approved = True
|
||||
uploaded_by = factory.SubFactory(UserFactory)
|
||||
|
||||
6
backend/tests/forms/__init__.py
Normal file
6
backend/tests/forms/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Form tests.
|
||||
|
||||
This module contains tests for Django forms to verify
|
||||
validation, widgets, and custom logic.
|
||||
"""
|
||||
315
backend/tests/forms/test_park_forms.py
Normal file
315
backend/tests/forms/test_park_forms.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
Tests for Park forms.
|
||||
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from decimal import Decimal
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.parks.forms import (
|
||||
ParkForm,
|
||||
ParkSearchForm,
|
||||
ParkAutocomplete,
|
||||
)
|
||||
|
||||
from tests.factories import (
|
||||
ParkFactory,
|
||||
OperatorCompanyFactory,
|
||||
LocationFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkForm(TestCase):
|
||||
"""Tests for ParkForm."""
|
||||
|
||||
def test__init__new_park__no_location_prefilled(self):
|
||||
"""Test initializing form for new park has no location prefilled."""
|
||||
form = ParkForm()
|
||||
|
||||
assert form.fields["latitude"].initial is None
|
||||
assert form.fields["longitude"].initial is None
|
||||
assert form.fields["city"].initial is None
|
||||
|
||||
def test__init__existing_park_with_location__prefills_location_fields(self):
|
||||
"""Test initializing form for existing park prefills location fields."""
|
||||
park = ParkFactory()
|
||||
# Create location via factory's post_generation hook
|
||||
|
||||
form = ParkForm(instance=park)
|
||||
|
||||
# Location should be prefilled if it exists
|
||||
if park.location.exists():
|
||||
location = park.location.first()
|
||||
assert form.fields["latitude"].initial == location.latitude
|
||||
assert form.fields["longitude"].initial == location.longitude
|
||||
assert form.fields["city"].initial == location.city
|
||||
|
||||
def test__clean_latitude__valid_value__returns_normalized_value(self):
|
||||
"""Test clean_latitude normalizes valid latitude."""
|
||||
operator = OperatorCompanyFactory()
|
||||
data = {
|
||||
"name": "Test Park",
|
||||
"operator": operator.pk,
|
||||
"status": "OPERATING",
|
||||
"latitude": "37.123456789", # Too many decimal places
|
||||
"longitude": "-122.123456",
|
||||
}
|
||||
|
||||
form = ParkForm(data=data)
|
||||
form.is_valid()
|
||||
|
||||
if "latitude" in form.cleaned_data:
|
||||
# Should be rounded to 6 decimal places
|
||||
assert len(form.cleaned_data["latitude"].split(".")[-1]) <= 6
|
||||
|
||||
def test__clean_latitude__out_of_range__returns_error(self):
|
||||
"""Test clean_latitude rejects out-of-range latitude."""
|
||||
operator = OperatorCompanyFactory()
|
||||
data = {
|
||||
"name": "Test Park",
|
||||
"operator": operator.pk,
|
||||
"status": "OPERATING",
|
||||
"latitude": "95.0", # Invalid: > 90
|
||||
"longitude": "-122.0",
|
||||
}
|
||||
|
||||
form = ParkForm(data=data)
|
||||
is_valid = form.is_valid()
|
||||
|
||||
assert not is_valid
|
||||
assert "latitude" in form.errors
|
||||
|
||||
def test__clean_latitude__negative_ninety__is_valid(self):
|
||||
"""Test clean_latitude accepts -90 (edge case)."""
|
||||
operator = OperatorCompanyFactory()
|
||||
data = {
|
||||
"name": "Test Park",
|
||||
"operator": operator.pk,
|
||||
"status": "OPERATING",
|
||||
"latitude": "-90.0",
|
||||
"longitude": "0.0",
|
||||
}
|
||||
|
||||
form = ParkForm(data=data)
|
||||
is_valid = form.is_valid()
|
||||
|
||||
# Should be valid (form may have other errors but not latitude)
|
||||
if not is_valid:
|
||||
assert "latitude" not in form.errors
|
||||
|
||||
def test__clean_longitude__valid_value__returns_normalized_value(self):
|
||||
"""Test clean_longitude normalizes valid longitude."""
|
||||
operator = OperatorCompanyFactory()
|
||||
data = {
|
||||
"name": "Test Park",
|
||||
"operator": operator.pk,
|
||||
"status": "OPERATING",
|
||||
"latitude": "37.0",
|
||||
"longitude": "-122.123456789", # Too many decimal places
|
||||
}
|
||||
|
||||
form = ParkForm(data=data)
|
||||
form.is_valid()
|
||||
|
||||
if "longitude" in form.cleaned_data:
|
||||
# Should be rounded to 6 decimal places
|
||||
assert len(form.cleaned_data["longitude"].split(".")[-1]) <= 6
|
||||
|
||||
def test__clean_longitude__out_of_range__returns_error(self):
|
||||
"""Test clean_longitude rejects out-of-range longitude."""
|
||||
operator = OperatorCompanyFactory()
|
||||
data = {
|
||||
"name": "Test Park",
|
||||
"operator": operator.pk,
|
||||
"status": "OPERATING",
|
||||
"latitude": "37.0",
|
||||
"longitude": "-200.0", # Invalid: < -180
|
||||
}
|
||||
|
||||
form = ParkForm(data=data)
|
||||
is_valid = form.is_valid()
|
||||
|
||||
assert not is_valid
|
||||
assert "longitude" in form.errors
|
||||
|
||||
def test__clean_longitude__positive_180__is_valid(self):
|
||||
"""Test clean_longitude accepts 180 (edge case)."""
|
||||
operator = OperatorCompanyFactory()
|
||||
data = {
|
||||
"name": "Test Park",
|
||||
"operator": operator.pk,
|
||||
"status": "OPERATING",
|
||||
"latitude": "0.0",
|
||||
"longitude": "180.0",
|
||||
}
|
||||
|
||||
form = ParkForm(data=data)
|
||||
is_valid = form.is_valid()
|
||||
|
||||
# Should be valid (form may have other errors but not longitude)
|
||||
if not is_valid:
|
||||
assert "longitude" not in form.errors
|
||||
|
||||
def test__save__new_park_with_location__creates_park_and_location(self):
|
||||
"""Test saving new park creates both park and location."""
|
||||
operator = OperatorCompanyFactory()
|
||||
data = {
|
||||
"name": "New Test Park",
|
||||
"operator": operator.pk,
|
||||
"status": "OPERATING",
|
||||
"latitude": "37.123456",
|
||||
"longitude": "-122.123456",
|
||||
"city": "San Francisco",
|
||||
"state": "CA",
|
||||
"country": "USA",
|
||||
}
|
||||
|
||||
form = ParkForm(data=data)
|
||||
|
||||
if form.is_valid():
|
||||
park = form.save()
|
||||
|
||||
assert park.name == "New Test Park"
|
||||
# Location should be created
|
||||
assert park.location.exists() or hasattr(park, "location")
|
||||
|
||||
def test__save__existing_park__updates_location(self):
|
||||
"""Test saving existing park updates location."""
|
||||
park = ParkFactory()
|
||||
|
||||
# Update data
|
||||
data = {
|
||||
"name": park.name,
|
||||
"operator": park.operator.pk,
|
||||
"status": park.status,
|
||||
"latitude": "40.0",
|
||||
"longitude": "-74.0",
|
||||
"city": "New York",
|
||||
"state": "NY",
|
||||
"country": "USA",
|
||||
}
|
||||
|
||||
form = ParkForm(instance=park, data=data)
|
||||
|
||||
if form.is_valid():
|
||||
updated_park = form.save()
|
||||
# Location should be updated
|
||||
assert updated_park.pk == park.pk
|
||||
|
||||
def test__meta__fields__includes_all_expected_fields(self):
|
||||
"""Test Meta.fields includes all expected park and location fields."""
|
||||
expected_fields = [
|
||||
"name",
|
||||
"description",
|
||||
"operator",
|
||||
"property_owner",
|
||||
"status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"operating_season",
|
||||
"size_acres",
|
||||
"website",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"street_address",
|
||||
"city",
|
||||
"state",
|
||||
"country",
|
||||
"postal_code",
|
||||
]
|
||||
|
||||
for field in expected_fields:
|
||||
assert field in ParkForm.Meta.fields
|
||||
|
||||
def test__widgets__latitude_longitude_hidden__are_hidden_inputs(self):
|
||||
"""Test latitude and longitude use HiddenInput widgets."""
|
||||
form = ParkForm()
|
||||
|
||||
assert form.fields["latitude"].widget.input_type == "hidden"
|
||||
assert form.fields["longitude"].widget.input_type == "hidden"
|
||||
|
||||
def test__widgets__text_fields__have_styling_classes(self):
|
||||
"""Test text fields have appropriate CSS classes."""
|
||||
form = ParkForm()
|
||||
|
||||
# Check city field has expected styling
|
||||
city_widget = form.fields["city"].widget
|
||||
assert "class" in city_widget.attrs
|
||||
assert "rounded-lg" in city_widget.attrs["class"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkSearchForm(TestCase):
|
||||
"""Tests for ParkSearchForm."""
|
||||
|
||||
def test__init__creates_park_field(self):
|
||||
"""Test initializing form creates park field."""
|
||||
form = ParkSearchForm()
|
||||
|
||||
assert "park" in form.fields
|
||||
|
||||
def test__park_field__uses_autocomplete_widget(self):
|
||||
"""Test park field uses AutocompleteWidget."""
|
||||
form = ParkSearchForm()
|
||||
|
||||
# Check the widget type
|
||||
widget = form.fields["park"].widget
|
||||
widget_class_name = widget.__class__.__name__
|
||||
assert "Autocomplete" in widget_class_name or "Select" in widget_class_name
|
||||
|
||||
def test__park_field__not_required(self):
|
||||
"""Test park field is not required."""
|
||||
form = ParkSearchForm()
|
||||
|
||||
assert form.fields["park"].required is False
|
||||
|
||||
def test__validate__empty_form__is_valid(self):
|
||||
"""Test empty form is valid."""
|
||||
form = ParkSearchForm(data={})
|
||||
|
||||
assert form.is_valid()
|
||||
|
||||
def test__validate__with_park__is_valid(self):
|
||||
"""Test form with valid park is valid."""
|
||||
park = ParkFactory()
|
||||
form = ParkSearchForm(data={"park": park.pk})
|
||||
|
||||
assert form.is_valid()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkAutocomplete(TestCase):
|
||||
"""Tests for ParkAutocomplete."""
|
||||
|
||||
def test__model__is_park(self):
|
||||
"""Test autocomplete model is Park."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
assert ParkAutocomplete.model == Park
|
||||
|
||||
def test__search_attrs__includes_name(self):
|
||||
"""Test search_attrs includes name field."""
|
||||
assert "name" in ParkAutocomplete.search_attrs
|
||||
|
||||
def test__search__matching_name__returns_results(self):
|
||||
"""Test searching by name returns matching parks."""
|
||||
park1 = ParkFactory(name="Cedar Point")
|
||||
park2 = ParkFactory(name="Kings Island")
|
||||
|
||||
# The autocomplete should return Cedar Point when searching for "Cedar"
|
||||
queryset = ParkAutocomplete.model.objects.filter(name__icontains="Cedar")
|
||||
|
||||
assert park1 in queryset
|
||||
assert park2 not in queryset
|
||||
|
||||
def test__search__no_match__returns_empty(self):
|
||||
"""Test searching with no match returns empty queryset."""
|
||||
ParkFactory(name="Cedar Point")
|
||||
|
||||
queryset = ParkAutocomplete.model.objects.filter(name__icontains="NoMatchHere")
|
||||
|
||||
assert queryset.count() == 0
|
||||
371
backend/tests/forms/test_ride_forms.py
Normal file
371
backend/tests/forms/test_ride_forms.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
Tests for Ride forms.
|
||||
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.rides.forms import (
|
||||
RideForm,
|
||||
RideSearchForm,
|
||||
)
|
||||
|
||||
from tests.factories import (
|
||||
ParkFactory,
|
||||
RideFactory,
|
||||
ParkAreaFactory,
|
||||
ManufacturerCompanyFactory,
|
||||
DesignerCompanyFactory,
|
||||
RideModelFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideForm(TestCase):
|
||||
"""Tests for RideForm."""
|
||||
|
||||
def test__init__no_park__shows_park_search_field(self):
|
||||
"""Test initializing without park shows park search field."""
|
||||
form = RideForm()
|
||||
|
||||
assert "park_search" in form.fields
|
||||
assert "park" in form.fields
|
||||
|
||||
def test__init__with_park__hides_park_search_field(self):
|
||||
"""Test initializing with park hides park search field."""
|
||||
park = ParkFactory()
|
||||
form = RideForm(park=park)
|
||||
|
||||
assert "park_search" not in form.fields
|
||||
assert "park" in form.fields
|
||||
assert form.fields["park"].initial == park
|
||||
|
||||
def test__init__with_park__populates_park_area_queryset(self):
|
||||
"""Test initializing with park populates park_area choices."""
|
||||
park = ParkFactory()
|
||||
area1 = ParkAreaFactory(park=park, name="Area 1")
|
||||
area2 = ParkAreaFactory(park=park, name="Area 2")
|
||||
|
||||
form = RideForm(park=park)
|
||||
|
||||
# Park area queryset should contain park's areas
|
||||
queryset = form.fields["park_area"].queryset
|
||||
assert area1 in queryset
|
||||
assert area2 in queryset
|
||||
|
||||
def test__init__without_park__park_area_disabled(self):
|
||||
"""Test initializing without park disables park_area."""
|
||||
form = RideForm()
|
||||
|
||||
assert form.fields["park_area"].widget.attrs.get("disabled") is True
|
||||
|
||||
def test__init__existing_ride__prefills_manufacturer(self):
|
||||
"""Test initializing with existing ride prefills manufacturer."""
|
||||
manufacturer = ManufacturerCompanyFactory(name="Test Manufacturer")
|
||||
ride = RideFactory(manufacturer=manufacturer)
|
||||
|
||||
form = RideForm(instance=ride)
|
||||
|
||||
assert form.fields["manufacturer_search"].initial == "Test Manufacturer"
|
||||
assert form.fields["manufacturer"].initial == manufacturer
|
||||
|
||||
def test__init__existing_ride__prefills_designer(self):
|
||||
"""Test initializing with existing ride prefills designer."""
|
||||
designer = DesignerCompanyFactory(name="Test Designer")
|
||||
ride = RideFactory(designer=designer)
|
||||
|
||||
form = RideForm(instance=ride)
|
||||
|
||||
assert form.fields["designer_search"].initial == "Test Designer"
|
||||
assert form.fields["designer"].initial == designer
|
||||
|
||||
def test__init__existing_ride__prefills_ride_model(self):
|
||||
"""Test initializing with existing ride prefills ride model."""
|
||||
ride_model = RideModelFactory(name="Test Model")
|
||||
ride = RideFactory(ride_model=ride_model)
|
||||
|
||||
form = RideForm(instance=ride)
|
||||
|
||||
assert form.fields["ride_model_search"].initial == "Test Model"
|
||||
assert form.fields["ride_model"].initial == ride_model
|
||||
|
||||
def test__init__existing_ride_without_park_arg__prefills_park_search(self):
|
||||
"""Test initializing with existing ride prefills park search."""
|
||||
park = ParkFactory(name="Test Park")
|
||||
ride = RideFactory(park=park)
|
||||
|
||||
form = RideForm(instance=ride)
|
||||
|
||||
assert form.fields["park_search"].initial == "Test Park"
|
||||
assert form.fields["park"].initial == park
|
||||
|
||||
def test__init__category_is_required(self):
|
||||
"""Test category field is required."""
|
||||
form = RideForm()
|
||||
|
||||
assert form.fields["category"].required is True
|
||||
|
||||
def test__init__date_fields_have_no_initial_value(self):
|
||||
"""Test date fields have no initial value."""
|
||||
form = RideForm()
|
||||
|
||||
assert form.fields["opening_date"].initial is None
|
||||
assert form.fields["closing_date"].initial is None
|
||||
assert form.fields["status_since"].initial is None
|
||||
|
||||
def test__field_order__matches_expected(self):
|
||||
"""Test fields are ordered correctly."""
|
||||
form = RideForm()
|
||||
|
||||
expected_order = [
|
||||
"park_search",
|
||||
"park",
|
||||
"park_area",
|
||||
"name",
|
||||
"manufacturer_search",
|
||||
"manufacturer",
|
||||
"designer_search",
|
||||
"designer",
|
||||
"ride_model_search",
|
||||
"ride_model",
|
||||
"category",
|
||||
]
|
||||
|
||||
# Get first 11 fields from form
|
||||
actual_order = list(form.fields.keys())[:11]
|
||||
|
||||
assert actual_order == expected_order
|
||||
|
||||
def test__validate__valid_data__is_valid(self):
|
||||
"""Test form is valid with all required data."""
|
||||
park = ParkFactory()
|
||||
manufacturer = ManufacturerCompanyFactory()
|
||||
|
||||
data = {
|
||||
"name": "Test Ride",
|
||||
"park": park.pk,
|
||||
"category": "RC", # Roller coaster
|
||||
"status": "OPERATING",
|
||||
"manufacturer": manufacturer.pk,
|
||||
}
|
||||
|
||||
form = RideForm(data=data)
|
||||
|
||||
# Remove park_search validation error by skipping it
|
||||
if "park_search" in form.errors:
|
||||
del form.errors["park_search"]
|
||||
|
||||
# Check if form would be valid otherwise
|
||||
assert "name" not in form.errors
|
||||
assert "category" not in form.errors
|
||||
|
||||
def test__validate__missing_name__returns_error(self):
|
||||
"""Test form is invalid without name."""
|
||||
park = ParkFactory()
|
||||
|
||||
data = {
|
||||
"park": park.pk,
|
||||
"category": "RC",
|
||||
"status": "OPERATING",
|
||||
}
|
||||
|
||||
form = RideForm(data=data)
|
||||
is_valid = form.is_valid()
|
||||
|
||||
assert not is_valid
|
||||
assert "name" in form.errors
|
||||
|
||||
def test__validate__missing_category__returns_error(self):
|
||||
"""Test form is invalid without category."""
|
||||
park = ParkFactory()
|
||||
|
||||
data = {
|
||||
"name": "Test Ride",
|
||||
"park": park.pk,
|
||||
"status": "OPERATING",
|
||||
}
|
||||
|
||||
form = RideForm(data=data)
|
||||
is_valid = form.is_valid()
|
||||
|
||||
assert not is_valid
|
||||
assert "category" in form.errors
|
||||
|
||||
def test__widgets__name_field__has_styling(self):
|
||||
"""Test name field has appropriate CSS classes."""
|
||||
form = RideForm()
|
||||
|
||||
name_widget = form.fields["name"].widget
|
||||
assert "class" in name_widget.attrs
|
||||
assert "rounded-lg" in name_widget.attrs["class"]
|
||||
|
||||
def test__widgets__category_field__has_htmx_attributes(self):
|
||||
"""Test category field has HTMX attributes."""
|
||||
form = RideForm()
|
||||
|
||||
category_widget = form.fields["category"].widget
|
||||
assert "hx-get" in category_widget.attrs
|
||||
assert "hx-target" in category_widget.attrs
|
||||
assert "hx-trigger" in category_widget.attrs
|
||||
|
||||
def test__widgets__status_field__has_alpine_attributes(self):
|
||||
"""Test status field has Alpine.js attributes."""
|
||||
form = RideForm()
|
||||
|
||||
status_widget = form.fields["status"].widget
|
||||
assert "x-model" in status_widget.attrs
|
||||
assert "@change" in status_widget.attrs
|
||||
|
||||
def test__widgets__closing_date__has_conditional_display(self):
|
||||
"""Test closing_date has conditional display logic."""
|
||||
form = RideForm()
|
||||
|
||||
closing_date_widget = form.fields["closing_date"].widget
|
||||
assert "x-show" in closing_date_widget.attrs
|
||||
|
||||
def test__meta__model__is_ride(self):
|
||||
"""Test Meta.model is Ride."""
|
||||
from apps.rides.models import Ride
|
||||
|
||||
assert RideForm.Meta.model == Ride
|
||||
|
||||
def test__meta__fields__includes_expected_fields(self):
|
||||
"""Test Meta.fields includes expected ride fields."""
|
||||
expected_fields = [
|
||||
"name",
|
||||
"category",
|
||||
"status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
"min_height_in",
|
||||
"max_height_in",
|
||||
"description",
|
||||
]
|
||||
|
||||
for field in expected_fields:
|
||||
assert field in RideForm.Meta.fields
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideSearchForm(TestCase):
|
||||
"""Tests for RideSearchForm."""
|
||||
|
||||
def test__init__creates_ride_field(self):
|
||||
"""Test initializing form creates ride field."""
|
||||
form = RideSearchForm()
|
||||
|
||||
assert "ride" in form.fields
|
||||
|
||||
def test__ride_field__not_required(self):
|
||||
"""Test ride field is not required."""
|
||||
form = RideSearchForm()
|
||||
|
||||
assert form.fields["ride"].required is False
|
||||
|
||||
def test__ride_field__uses_select_widget(self):
|
||||
"""Test ride field uses Select widget."""
|
||||
form = RideSearchForm()
|
||||
|
||||
widget = form.fields["ride"].widget
|
||||
assert "Select" in widget.__class__.__name__
|
||||
|
||||
def test__ride_field__has_htmx_attributes(self):
|
||||
"""Test ride field has HTMX attributes."""
|
||||
form = RideSearchForm()
|
||||
|
||||
ride_widget = form.fields["ride"].widget
|
||||
assert "hx-get" in ride_widget.attrs
|
||||
assert "hx-trigger" in ride_widget.attrs
|
||||
assert "hx-target" in ride_widget.attrs
|
||||
|
||||
def test__validate__empty_form__is_valid(self):
|
||||
"""Test empty form is valid."""
|
||||
form = RideSearchForm(data={})
|
||||
|
||||
assert form.is_valid()
|
||||
|
||||
def test__validate__with_ride__is_valid(self):
|
||||
"""Test form with valid ride is valid."""
|
||||
ride = RideFactory()
|
||||
form = RideSearchForm(data={"ride": ride.pk})
|
||||
|
||||
assert form.is_valid()
|
||||
|
||||
def test__validate__with_invalid_ride__is_invalid(self):
|
||||
"""Test form with invalid ride is invalid."""
|
||||
form = RideSearchForm(data={"ride": 99999})
|
||||
|
||||
assert not form.is_valid()
|
||||
assert "ride" in form.errors
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideFormWithParkAreas(TestCase):
|
||||
"""Tests for RideForm park area functionality."""
|
||||
|
||||
def test__park_area__queryset_empty_without_park(self):
|
||||
"""Test park_area queryset is empty when no park provided."""
|
||||
form = RideForm()
|
||||
|
||||
# When no park, the queryset should be empty (none())
|
||||
queryset = form.fields["park_area"].queryset
|
||||
assert queryset.count() == 0
|
||||
|
||||
def test__park_area__queryset_filtered_to_park(self):
|
||||
"""Test park_area queryset only contains areas from given park."""
|
||||
park1 = ParkFactory()
|
||||
park2 = ParkFactory()
|
||||
area1 = ParkAreaFactory(park=park1)
|
||||
area2 = ParkAreaFactory(park=park2)
|
||||
|
||||
form = RideForm(park=park1)
|
||||
|
||||
queryset = form.fields["park_area"].queryset
|
||||
assert area1 in queryset
|
||||
assert area2 not in queryset
|
||||
|
||||
def test__park_area__is_optional(self):
|
||||
"""Test park_area field is optional."""
|
||||
form = RideForm()
|
||||
|
||||
assert form.fields["park_area"].required is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideFormFieldOrder(TestCase):
|
||||
"""Tests for RideForm field ordering."""
|
||||
|
||||
def test__field_order__park_fields_first(self):
|
||||
"""Test park-related fields come first."""
|
||||
form = RideForm()
|
||||
|
||||
field_names = list(form.fields.keys())
|
||||
|
||||
# park_search should be first
|
||||
assert field_names[0] == "park_search"
|
||||
assert field_names[1] == "park"
|
||||
assert field_names[2] == "park_area"
|
||||
|
||||
def test__field_order__name_after_park(self):
|
||||
"""Test name field comes after park fields."""
|
||||
form = RideForm()
|
||||
|
||||
field_names = list(form.fields.keys())
|
||||
|
||||
name_index = field_names.index("name")
|
||||
park_index = field_names.index("park")
|
||||
|
||||
assert name_index > park_index
|
||||
|
||||
def test__field_order__description_last(self):
|
||||
"""Test description is near the end."""
|
||||
form = RideForm()
|
||||
|
||||
field_names = list(form.fields.keys())
|
||||
|
||||
# Description should be one of the last fields
|
||||
description_index = field_names.index("description")
|
||||
assert description_index > len(field_names) // 2
|
||||
230
backend/tests/integration/test_fsm_transition_workflow.py
Normal file
230
backend/tests/integration/test_fsm_transition_workflow.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Integration tests for FSM (Finite State Machine) transition workflows.
|
||||
|
||||
These tests verify the complete state transition workflows for
|
||||
Parks and Rides using the FSM implementation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import date, timedelta
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
|
||||
from tests.factories import (
|
||||
ParkFactory,
|
||||
RideFactory,
|
||||
UserFactory,
|
||||
ParkAreaFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkFSMTransitions(TestCase):
|
||||
"""Integration tests for Park FSM transitions."""
|
||||
|
||||
def test__park_operating_to_closed_temp__transition_succeeds(self):
|
||||
"""Test transitioning operating park to temporarily closed."""
|
||||
park = ParkFactory(status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
park.close_temporarily(user=user)
|
||||
|
||||
assert park.status == "CLOSED_TEMP"
|
||||
|
||||
def test__park_closed_temp_to_operating__transition_succeeds(self):
|
||||
"""Test reopening temporarily closed park."""
|
||||
park = ParkFactory(status="CLOSED_TEMP")
|
||||
user = UserFactory()
|
||||
|
||||
park.open(user=user)
|
||||
|
||||
assert park.status == "OPERATING"
|
||||
|
||||
def test__park_operating_to_closed_perm__transition_succeeds(self):
|
||||
"""Test closing operating park permanently."""
|
||||
park = ParkFactory(status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
park.close_permanently(user=user)
|
||||
|
||||
assert park.status == "CLOSED_PERM"
|
||||
|
||||
def test__park_closed_perm_to_operating__transition_not_allowed(self):
|
||||
"""Test permanently closed park cannot reopen."""
|
||||
park = ParkFactory(status="CLOSED_PERM")
|
||||
user = UserFactory()
|
||||
|
||||
# This should fail - can't reopen permanently closed park
|
||||
with pytest.raises(Exception):
|
||||
park.open(user=user)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideFSMTransitions(TestCase):
|
||||
"""Integration tests for Ride FSM transitions."""
|
||||
|
||||
def test__ride_operating_to_closed_temp__transition_succeeds(self):
|
||||
"""Test transitioning operating ride to temporarily closed."""
|
||||
ride = RideFactory(status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
ride.close_temporarily(user=user)
|
||||
|
||||
assert ride.status == "CLOSED_TEMP"
|
||||
|
||||
def test__ride_closed_temp_to_operating__transition_succeeds(self):
|
||||
"""Test reopening temporarily closed ride."""
|
||||
ride = RideFactory(status="CLOSED_TEMP")
|
||||
user = UserFactory()
|
||||
|
||||
ride.open(user=user)
|
||||
|
||||
assert ride.status == "OPERATING"
|
||||
|
||||
def test__ride_operating_to_sbno__transition_succeeds(self):
|
||||
"""Test transitioning operating ride to SBNO (Standing But Not Operating)."""
|
||||
ride = RideFactory(status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
ride.mark_sbno(user=user)
|
||||
|
||||
assert ride.status == "SBNO"
|
||||
|
||||
def test__ride_sbno_to_operating__transition_succeeds(self):
|
||||
"""Test reopening SBNO ride."""
|
||||
ride = RideFactory(status="SBNO")
|
||||
user = UserFactory()
|
||||
|
||||
ride.open(user=user)
|
||||
|
||||
assert ride.status == "OPERATING"
|
||||
|
||||
def test__ride_operating_to_closing__with_date__transition_succeeds(self):
|
||||
"""Test scheduling ride for closing."""
|
||||
ride = RideFactory(status="OPERATING")
|
||||
user = UserFactory()
|
||||
closing_date = date.today() + timedelta(days=30)
|
||||
|
||||
ride.mark_closing(
|
||||
closing_date=closing_date,
|
||||
post_closing_status="DEMOLISHED",
|
||||
user=user,
|
||||
)
|
||||
|
||||
assert ride.status == "CLOSING"
|
||||
assert ride.closing_date == closing_date
|
||||
assert ride.post_closing_status == "DEMOLISHED"
|
||||
|
||||
def test__ride_closing_to_demolished__transition_succeeds(self):
|
||||
"""Test transitioning closing ride to demolished."""
|
||||
ride = RideFactory(status="CLOSING")
|
||||
ride.post_closing_status = "DEMOLISHED"
|
||||
ride.save()
|
||||
user = UserFactory()
|
||||
|
||||
ride.demolish(user=user)
|
||||
|
||||
assert ride.status == "DEMOLISHED"
|
||||
|
||||
def test__ride_operating_to_relocated__transition_succeeds(self):
|
||||
"""Test marking ride as relocated."""
|
||||
ride = RideFactory(status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
ride.relocate(user=user)
|
||||
|
||||
assert ride.status == "RELOCATED"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideRelocationWorkflow(TestCase):
|
||||
"""Integration tests for ride relocation workflow."""
|
||||
|
||||
def test__relocate_ride__to_new_park__updates_park(self):
|
||||
"""Test relocating ride to new park updates the park relationship."""
|
||||
old_park = ParkFactory(name="Old Park")
|
||||
new_park = ParkFactory(name="New Park")
|
||||
ride = RideFactory(park=old_park, status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
# Mark as relocated first
|
||||
ride.relocate(user=user)
|
||||
assert ride.status == "RELOCATED"
|
||||
|
||||
# Move to new park
|
||||
ride.move_to_park(new_park, clear_park_area=True)
|
||||
|
||||
assert ride.park == new_park
|
||||
assert ride.park_area is None # Cleared during relocation
|
||||
|
||||
def test__relocate_ride__clears_park_area(self):
|
||||
"""Test relocating ride clears park area."""
|
||||
park = ParkFactory()
|
||||
area = ParkAreaFactory(park=park)
|
||||
new_park = ParkFactory()
|
||||
|
||||
ride = RideFactory(park=park, park_area=area, status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
ride.relocate(user=user)
|
||||
ride.move_to_park(new_park, clear_park_area=True)
|
||||
|
||||
assert ride.park_area is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideStatusTransitionHistory(TestCase):
|
||||
"""Integration tests for ride status transition history."""
|
||||
|
||||
def test__multiple_transitions__records_status_since(self):
|
||||
"""Test multiple transitions update status_since correctly."""
|
||||
ride = RideFactory(status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
# First transition
|
||||
ride.close_temporarily(user=user)
|
||||
first_status_since = ride.status_since
|
||||
|
||||
assert ride.status == "CLOSED_TEMP"
|
||||
|
||||
# Second transition
|
||||
ride.open(user=user)
|
||||
second_status_since = ride.status_since
|
||||
|
||||
assert ride.status == "OPERATING"
|
||||
# status_since should be updated for new transition
|
||||
assert second_status_since >= first_status_since
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkRideCascadeStatus(TestCase):
|
||||
"""Integration tests for park status affecting rides."""
|
||||
|
||||
def test__close_park__does_not_auto_close_rides(self):
|
||||
"""Test closing park doesn't automatically close rides."""
|
||||
park = ParkFactory(status="OPERATING")
|
||||
ride = RideFactory(park=park, status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
# Close the park
|
||||
park.close_temporarily(user=user)
|
||||
|
||||
# Ride should still be operating (business decision)
|
||||
ride.refresh_from_db()
|
||||
assert ride.status == "OPERATING" # Rides keep their independent status
|
||||
|
||||
def test__reopen_park__allows_ride_operation(self):
|
||||
"""Test reopening park allows rides to continue operating."""
|
||||
park = ParkFactory(status="CLOSED_TEMP")
|
||||
ride = RideFactory(park=park, status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
# Reopen park
|
||||
park.open(user=user)
|
||||
|
||||
assert park.status == "OPERATING"
|
||||
ride.refresh_from_db()
|
||||
assert ride.status == "OPERATING"
|
||||
233
backend/tests/integration/test_park_creation_workflow.py
Normal file
233
backend/tests/integration/test_park_creation_workflow.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Integration tests for park creation workflow.
|
||||
|
||||
These tests verify the complete workflow of park creation including
|
||||
validation, location creation, and related operations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
from django.db import transaction
|
||||
|
||||
from apps.parks.models import Park, ParkArea, ParkReview
|
||||
from apps.parks.forms import ParkForm
|
||||
|
||||
from tests.factories import (
|
||||
ParkFactory,
|
||||
ParkAreaFactory,
|
||||
OperatorCompanyFactory,
|
||||
UserFactory,
|
||||
RideFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkCreationWorkflow(TestCase):
|
||||
"""Integration tests for complete park creation workflow."""
|
||||
|
||||
def test__create_park_with_form__valid_data__creates_park_and_location(self):
|
||||
"""Test creating a park with form creates both park and location."""
|
||||
operator = OperatorCompanyFactory()
|
||||
|
||||
data = {
|
||||
"name": "New Test Park",
|
||||
"operator": operator.pk,
|
||||
"status": "OPERATING",
|
||||
"latitude": "37.123456",
|
||||
"longitude": "-122.654321",
|
||||
"city": "San Francisco",
|
||||
"state": "CA",
|
||||
"country": "USA",
|
||||
}
|
||||
|
||||
form = ParkForm(data=data)
|
||||
|
||||
if form.is_valid():
|
||||
park = form.save()
|
||||
|
||||
# Verify park was created
|
||||
assert park.pk is not None
|
||||
assert park.name == "New Test Park"
|
||||
assert park.operator == operator
|
||||
|
||||
# Verify location was created
|
||||
if park.location.exists():
|
||||
location = park.location.first()
|
||||
assert location.city == "San Francisco"
|
||||
assert location.country == "USA"
|
||||
|
||||
def test__create_park__with_areas__creates_complete_structure(self):
|
||||
"""Test creating a park with areas creates complete structure."""
|
||||
park = ParkFactory()
|
||||
|
||||
# Add areas
|
||||
area1 = ParkAreaFactory(park=park, name="Main Entrance")
|
||||
area2 = ParkAreaFactory(park=park, name="Thrill Zone")
|
||||
area3 = ParkAreaFactory(park=park, name="Kids Area")
|
||||
|
||||
# Verify structure
|
||||
assert park.areas.count() == 3
|
||||
assert park.areas.filter(name="Main Entrance").exists()
|
||||
assert park.areas.filter(name="Thrill Zone").exists()
|
||||
assert park.areas.filter(name="Kids Area").exists()
|
||||
|
||||
def test__create_park__with_rides__updates_counts(self):
|
||||
"""Test creating a park with rides updates ride counts."""
|
||||
park = ParkFactory()
|
||||
|
||||
# Add rides
|
||||
RideFactory(park=park, category="RC") # Roller coaster
|
||||
RideFactory(park=park, category="RC") # Roller coaster
|
||||
RideFactory(park=park, category="TR") # Thrill ride
|
||||
RideFactory(park=park, category="DR") # Dark ride
|
||||
|
||||
# Verify ride counts
|
||||
assert park.rides.count() == 4
|
||||
assert park.rides.filter(category="RC").count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkUpdateWorkflow(TestCase):
|
||||
"""Integration tests for park update workflow."""
|
||||
|
||||
def test__update_park__changes_status__updates_correctly(self):
|
||||
"""Test updating park status updates correctly."""
|
||||
park = ParkFactory(status="OPERATING")
|
||||
|
||||
# Update via FSM transition
|
||||
park.close_temporarily()
|
||||
|
||||
park.refresh_from_db()
|
||||
assert park.status == "CLOSED_TEMP"
|
||||
|
||||
def test__update_park_location__updates_location_record(self):
|
||||
"""Test updating park location updates the location record."""
|
||||
park = ParkFactory()
|
||||
|
||||
form_data = {
|
||||
"name": park.name,
|
||||
"operator": park.operator.pk,
|
||||
"status": park.status,
|
||||
"city": "New City",
|
||||
"state": "NY",
|
||||
"country": "USA",
|
||||
}
|
||||
|
||||
form = ParkForm(instance=park, data=form_data)
|
||||
|
||||
if form.is_valid():
|
||||
updated_park = form.save()
|
||||
|
||||
# Verify location was updated
|
||||
if updated_park.location.exists():
|
||||
location = updated_park.location.first()
|
||||
assert location.city == "New City"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkReviewWorkflow(TestCase):
|
||||
"""Integration tests for park review workflow."""
|
||||
|
||||
def test__add_review__updates_park_rating(self):
|
||||
"""Test adding a review affects park's average rating."""
|
||||
park = ParkFactory()
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
|
||||
# Add reviews
|
||||
from tests.factories import ParkReviewFactory
|
||||
|
||||
ParkReviewFactory(park=park, user=user1, rating=8, is_published=True)
|
||||
ParkReviewFactory(park=park, user=user2, rating=10, is_published=True)
|
||||
|
||||
# Calculate average
|
||||
avg = park.reviews.filter(is_published=True).values_list(
|
||||
"rating", flat=True
|
||||
)
|
||||
calculated_avg = sum(avg) / len(avg)
|
||||
|
||||
assert calculated_avg == 9.0
|
||||
|
||||
def test__unpublish_review__excludes_from_rating(self):
|
||||
"""Test unpublishing a review excludes it from rating calculation."""
|
||||
park = ParkFactory()
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
|
||||
from tests.factories import ParkReviewFactory
|
||||
|
||||
review1 = ParkReviewFactory(park=park, user=user1, rating=10, is_published=True)
|
||||
review2 = ParkReviewFactory(park=park, user=user2, rating=2, is_published=True)
|
||||
|
||||
# Unpublish the low rating
|
||||
review2.is_published = False
|
||||
review2.save()
|
||||
|
||||
# Calculate average - should only include published reviews
|
||||
published_reviews = park.reviews.filter(is_published=True)
|
||||
assert published_reviews.count() == 1
|
||||
assert published_reviews.first().rating == 10
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkAreaRideWorkflow(TestCase):
|
||||
"""Integration tests for park area and ride workflow."""
|
||||
|
||||
def test__add_ride_to_area__associates_correctly(self):
|
||||
"""Test adding a ride to an area associates them correctly."""
|
||||
park = ParkFactory()
|
||||
area = ParkAreaFactory(park=park, name="Thrill Zone")
|
||||
|
||||
ride = RideFactory(park=park, park_area=area, name="Super Coaster")
|
||||
|
||||
assert ride.park_area == area
|
||||
assert ride in area.rides.all()
|
||||
|
||||
def test__delete_area__handles_rides_correctly(self):
|
||||
"""Test deleting an area handles associated rides."""
|
||||
park = ParkFactory()
|
||||
area = ParkAreaFactory(park=park)
|
||||
ride = RideFactory(park=park, park_area=area)
|
||||
|
||||
ride_pk = ride.pk
|
||||
|
||||
# Delete area - ride should have park_area set to NULL
|
||||
area.delete()
|
||||
|
||||
ride.refresh_from_db()
|
||||
assert ride.park_area is None
|
||||
assert ride.pk == ride_pk # Ride still exists
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkOperatorWorkflow(TestCase):
|
||||
"""Integration tests for park operator workflow."""
|
||||
|
||||
def test__change_operator__updates_park(self):
|
||||
"""Test changing park operator updates the relationship."""
|
||||
old_operator = OperatorCompanyFactory(name="Old Operator")
|
||||
new_operator = OperatorCompanyFactory(name="New Operator")
|
||||
park = ParkFactory(operator=old_operator)
|
||||
|
||||
# Change operator
|
||||
park.operator = new_operator
|
||||
park.save()
|
||||
|
||||
park.refresh_from_db()
|
||||
assert park.operator == new_operator
|
||||
assert park.operator.name == "New Operator"
|
||||
|
||||
def test__operator_with_multiple_parks__lists_all_parks(self):
|
||||
"""Test operator with multiple parks lists all parks."""
|
||||
operator = OperatorCompanyFactory()
|
||||
|
||||
park1 = ParkFactory(operator=operator, name="Park One")
|
||||
park2 = ParkFactory(operator=operator, name="Park Two")
|
||||
park3 = ParkFactory(operator=operator, name="Park Three")
|
||||
|
||||
# Verify operator's parks
|
||||
operator_parks = operator.operated_parks.all()
|
||||
assert operator_parks.count() == 3
|
||||
assert park1 in operator_parks
|
||||
assert park2 in operator_parks
|
||||
assert park3 in operator_parks
|
||||
224
backend/tests/integration/test_photo_upload_workflow.py
Normal file
224
backend/tests/integration/test_photo_upload_workflow.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Integration tests for photo upload workflow.
|
||||
|
||||
These tests verify the complete workflow of photo uploads including
|
||||
validation, processing, and moderation.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from django.test import TestCase
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
from apps.parks.models import ParkPhoto
|
||||
from apps.rides.models import RidePhoto
|
||||
from apps.parks.services.media_service import ParkMediaService
|
||||
|
||||
from tests.factories import (
|
||||
ParkFactory,
|
||||
RideFactory,
|
||||
ParkPhotoFactory,
|
||||
RidePhotoFactory,
|
||||
UserFactory,
|
||||
StaffUserFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkPhotoUploadWorkflow(TestCase):
|
||||
"""Integration tests for park photo upload workflow."""
|
||||
|
||||
@patch("apps.parks.services.media_service.MediaService.validate_image_file")
|
||||
@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
|
||||
):
|
||||
"""Test uploading photo creates a pending photo."""
|
||||
mock_validate.return_value = (True, None)
|
||||
mock_process.return_value = Mock()
|
||||
mock_caption.return_value = "Photo by testuser"
|
||||
mock_exif.return_value = None
|
||||
|
||||
park = ParkFactory()
|
||||
user = UserFactory()
|
||||
image = SimpleUploadedFile("test.jpg", b"image data", content_type="image/jpeg")
|
||||
|
||||
photo = ParkMediaService.upload_photo(
|
||||
park=park,
|
||||
image_file=image,
|
||||
user=user,
|
||||
caption="Test photo",
|
||||
auto_approve=False,
|
||||
)
|
||||
|
||||
assert photo.is_approved is False
|
||||
assert photo.uploaded_by == user
|
||||
assert photo.park == park
|
||||
|
||||
@patch("apps.parks.services.media_service.MediaService.validate_image_file")
|
||||
@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__auto_approve__creates_approved_photo(
|
||||
self, mock_exif, mock_caption, mock_process, mock_validate
|
||||
):
|
||||
"""Test uploading photo with auto_approve creates approved photo."""
|
||||
mock_validate.return_value = (True, None)
|
||||
mock_process.return_value = Mock()
|
||||
mock_caption.return_value = "Photo by testuser"
|
||||
mock_exif.return_value = None
|
||||
|
||||
park = ParkFactory()
|
||||
user = UserFactory()
|
||||
image = SimpleUploadedFile("test.jpg", b"image data", content_type="image/jpeg")
|
||||
|
||||
photo = ParkMediaService.upload_photo(
|
||||
park=park,
|
||||
image_file=image,
|
||||
user=user,
|
||||
auto_approve=True,
|
||||
)
|
||||
|
||||
assert photo.is_approved is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPhotoModerationWorkflow(TestCase):
|
||||
"""Integration tests for photo moderation workflow."""
|
||||
|
||||
def test__approve_photo__marks_as_approved(self):
|
||||
"""Test approving a photo marks it as approved."""
|
||||
photo = ParkPhotoFactory(is_approved=False)
|
||||
moderator = StaffUserFactory()
|
||||
|
||||
result = ParkMediaService.approve_photo(photo, moderator)
|
||||
|
||||
photo.refresh_from_db()
|
||||
assert result is True
|
||||
assert photo.is_approved is True
|
||||
|
||||
def test__bulk_approve_photos__approves_all(self):
|
||||
"""Test bulk approving photos approves all photos."""
|
||||
park = ParkFactory()
|
||||
photos = [
|
||||
ParkPhotoFactory(park=park, is_approved=False),
|
||||
ParkPhotoFactory(park=park, is_approved=False),
|
||||
ParkPhotoFactory(park=park, is_approved=False),
|
||||
]
|
||||
moderator = StaffUserFactory()
|
||||
|
||||
count = ParkMediaService.bulk_approve_photos(photos, moderator)
|
||||
|
||||
assert count == 3
|
||||
for photo in photos:
|
||||
photo.refresh_from_db()
|
||||
assert photo.is_approved is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPrimaryPhotoWorkflow(TestCase):
|
||||
"""Integration tests for primary photo workflow."""
|
||||
|
||||
def test__set_primary_photo__unsets_previous_primary(self):
|
||||
"""Test setting primary photo unsets previous primary."""
|
||||
park = ParkFactory()
|
||||
old_primary = ParkPhotoFactory(park=park, is_primary=True)
|
||||
new_primary = ParkPhotoFactory(park=park, is_primary=False)
|
||||
|
||||
result = ParkMediaService.set_primary_photo(park, new_primary)
|
||||
|
||||
old_primary.refresh_from_db()
|
||||
new_primary.refresh_from_db()
|
||||
|
||||
assert result is True
|
||||
assert old_primary.is_primary is False
|
||||
assert new_primary.is_primary is True
|
||||
|
||||
def test__get_primary_photo__returns_correct_photo(self):
|
||||
"""Test get_primary_photo returns the primary photo."""
|
||||
park = ParkFactory()
|
||||
ParkPhotoFactory(park=park, is_primary=False, is_approved=True)
|
||||
primary = ParkPhotoFactory(park=park, is_primary=True, is_approved=True)
|
||||
ParkPhotoFactory(park=park, is_primary=False, is_approved=True)
|
||||
|
||||
result = ParkMediaService.get_primary_photo(park)
|
||||
|
||||
assert result == primary
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPhotoStatsWorkflow(TestCase):
|
||||
"""Integration tests for photo statistics workflow."""
|
||||
|
||||
def test__get_photo_stats__returns_accurate_counts(self):
|
||||
"""Test get_photo_stats returns accurate statistics."""
|
||||
park = ParkFactory()
|
||||
|
||||
# Create various photos
|
||||
ParkPhotoFactory(park=park, is_approved=True)
|
||||
ParkPhotoFactory(park=park, is_approved=True)
|
||||
ParkPhotoFactory(park=park, is_approved=False)
|
||||
ParkPhotoFactory(park=park, is_approved=True, is_primary=True)
|
||||
|
||||
stats = ParkMediaService.get_photo_stats(park)
|
||||
|
||||
assert stats["total_photos"] == 4
|
||||
assert stats["approved_photos"] == 3
|
||||
assert stats["pending_photos"] == 1
|
||||
assert stats["has_primary"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPhotoDeleteWorkflow(TestCase):
|
||||
"""Integration tests for photo deletion workflow."""
|
||||
|
||||
def test__delete_photo__removes_photo(self):
|
||||
"""Test deleting a photo removes it from database."""
|
||||
photo = ParkPhotoFactory()
|
||||
photo_id = photo.pk
|
||||
moderator = StaffUserFactory()
|
||||
|
||||
result = ParkMediaService.delete_photo(photo, moderator)
|
||||
|
||||
assert result is True
|
||||
assert not ParkPhoto.objects.filter(pk=photo_id).exists()
|
||||
|
||||
def test__delete_primary_photo__removes_primary(self):
|
||||
"""Test deleting primary photo removes primary status."""
|
||||
park = ParkFactory()
|
||||
primary = ParkPhotoFactory(park=park, is_primary=True)
|
||||
moderator = StaffUserFactory()
|
||||
|
||||
ParkMediaService.delete_photo(primary, moderator)
|
||||
|
||||
# Park should no longer have a primary photo
|
||||
result = ParkMediaService.get_primary_photo(park)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRidePhotoWorkflow(TestCase):
|
||||
"""Integration tests for ride photo workflow."""
|
||||
|
||||
def test__ride_photo__includes_park_info(self):
|
||||
"""Test ride photo includes park information."""
|
||||
ride = RideFactory()
|
||||
photo = RidePhotoFactory(ride=ride)
|
||||
|
||||
# Photo should have access to park through ride
|
||||
assert photo.ride.park is not None
|
||||
assert photo.ride.park.name is not None
|
||||
|
||||
def test__ride_photo__different_types(self):
|
||||
"""Test ride photos can have different types."""
|
||||
ride = RideFactory()
|
||||
|
||||
exterior = RidePhotoFactory(ride=ride, photo_type="exterior")
|
||||
queue = RidePhotoFactory(ride=ride, photo_type="queue")
|
||||
onride = RidePhotoFactory(ride=ride, photo_type="onride")
|
||||
|
||||
assert ride.photos.filter(photo_type="exterior").count() == 1
|
||||
assert ride.photos.filter(photo_type="queue").count() == 1
|
||||
assert ride.photos.filter(photo_type="onride").count() == 1
|
||||
6
backend/tests/managers/__init__.py
Normal file
6
backend/tests/managers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Manager and QuerySet tests.
|
||||
|
||||
This module contains tests for custom managers and querysets
|
||||
to verify filtering, optimization, and annotation logic.
|
||||
"""
|
||||
354
backend/tests/managers/test_core_managers.py
Normal file
354
backend/tests/managers/test_core_managers.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Tests for Core managers and querysets.
|
||||
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from apps.core.managers import (
|
||||
BaseQuerySet,
|
||||
BaseManager,
|
||||
LocationQuerySet,
|
||||
LocationManager,
|
||||
ReviewableQuerySet,
|
||||
ReviewableManager,
|
||||
HierarchicalQuerySet,
|
||||
HierarchicalManager,
|
||||
TimestampedQuerySet,
|
||||
TimestampedManager,
|
||||
StatusQuerySet,
|
||||
StatusManager,
|
||||
)
|
||||
|
||||
from tests.factories import (
|
||||
ParkFactory,
|
||||
ParkReviewFactory,
|
||||
RideFactory,
|
||||
UserFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBaseQuerySet(TestCase):
|
||||
"""Tests for BaseQuerySet."""
|
||||
|
||||
def test__active__filters_active_records(self):
|
||||
"""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(
|
||||
username="active", email="active@test.com", password="test", is_active=True
|
||||
)
|
||||
inactive_user = User.objects.create_user(
|
||||
username="inactive", email="inactive@test.com", password="test", is_active=False
|
||||
)
|
||||
|
||||
result = User.objects.filter(is_active=True)
|
||||
|
||||
assert active_user in result
|
||||
assert inactive_user not in result
|
||||
|
||||
def test__recent__filters_recently_created(self):
|
||||
"""Test recent filters by created_at within days."""
|
||||
park = ParkFactory()
|
||||
# Created just now, should be in recent
|
||||
|
||||
from apps.parks.models import Park
|
||||
result = Park.objects.recent(days=30)
|
||||
|
||||
assert park in result
|
||||
|
||||
def test__search__searches_by_name(self):
|
||||
"""Test search filters by name field."""
|
||||
park1 = ParkFactory(name="Cedar Point")
|
||||
park2 = ParkFactory(name="Kings Island")
|
||||
|
||||
from apps.parks.models import Park
|
||||
result = Park.objects.search(query="Cedar")
|
||||
|
||||
assert park1 in result
|
||||
assert park2 not in result
|
||||
|
||||
def test__search__empty_query__returns_all(self):
|
||||
"""Test search with empty query returns all records."""
|
||||
park1 = ParkFactory()
|
||||
park2 = ParkFactory()
|
||||
|
||||
from apps.parks.models import Park
|
||||
result = Park.objects.search(query="")
|
||||
|
||||
assert park1 in result
|
||||
assert park2 in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestLocationQuerySet(TestCase):
|
||||
"""Tests for LocationQuerySet."""
|
||||
|
||||
def test__by_country__filters_by_country(self):
|
||||
"""Test by_country filters by country field."""
|
||||
# Create parks with locations through factory
|
||||
us_park = ParkFactory()
|
||||
# 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()
|
||||
assert us_park in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReviewableQuerySet(TestCase):
|
||||
"""Tests for ReviewableQuerySet."""
|
||||
|
||||
def test__with_review_stats__annotates_review_count(self):
|
||||
"""Test with_review_stats adds review count annotation."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
park = ParkFactory()
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
ParkReviewFactory(park=park, user=user1, is_published=True)
|
||||
ParkReviewFactory(park=park, user=user2, is_published=True)
|
||||
|
||||
result = Park.objects.with_review_stats().get(pk=park.pk)
|
||||
|
||||
assert result.review_count == 2
|
||||
|
||||
def test__with_review_stats__calculates_average_rating(self):
|
||||
"""Test with_review_stats calculates average rating."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
park = ParkFactory()
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
ParkReviewFactory(park=park, user=user1, is_published=True, rating=8)
|
||||
ParkReviewFactory(park=park, user=user2, is_published=True, rating=10)
|
||||
|
||||
result = Park.objects.with_review_stats().get(pk=park.pk)
|
||||
|
||||
assert result.average_rating == 9.0
|
||||
|
||||
def test__with_review_stats__excludes_unpublished(self):
|
||||
"""Test with_review_stats excludes unpublished reviews."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
park = ParkFactory()
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
ParkReviewFactory(park=park, user=user1, is_published=True, rating=10)
|
||||
ParkReviewFactory(park=park, user=user2, is_published=False, rating=2)
|
||||
|
||||
result = Park.objects.with_review_stats().get(pk=park.pk)
|
||||
|
||||
assert result.review_count == 1
|
||||
assert result.average_rating == 10.0
|
||||
|
||||
def test__highly_rated__filters_by_minimum_rating(self):
|
||||
"""Test highly_rated filters by minimum average rating."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
high_rated = ParkFactory()
|
||||
low_rated = ParkFactory()
|
||||
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
ParkReviewFactory(park=high_rated, user=user1, is_published=True, rating=9)
|
||||
ParkReviewFactory(park=low_rated, user=user2, is_published=True, rating=4)
|
||||
|
||||
result = Park.objects.highly_rated(min_rating=8.0)
|
||||
|
||||
assert high_rated in result
|
||||
assert low_rated not in result
|
||||
|
||||
def test__recently_reviewed__filters_by_recent_reviews(self):
|
||||
"""Test recently_reviewed filters parks with recent reviews."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
reviewed_park = ParkFactory()
|
||||
user = UserFactory()
|
||||
ParkReviewFactory(park=reviewed_park, user=user, is_published=True)
|
||||
|
||||
result = Park.objects.get_queryset().recently_reviewed(days=30)
|
||||
|
||||
assert reviewed_park in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestStatusQuerySet(TestCase):
|
||||
"""Tests for StatusQuerySet."""
|
||||
|
||||
def test__with_status__single_status__filters_correctly(self):
|
||||
"""Test with_status filters by single status."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
operating = ParkFactory(status="OPERATING")
|
||||
closed = ParkFactory(status="CLOSED_PERM")
|
||||
|
||||
result = Park.objects.get_queryset().with_status(status="OPERATING")
|
||||
|
||||
assert operating in result
|
||||
assert closed not in result
|
||||
|
||||
def test__with_status__multiple_statuses__filters_correctly(self):
|
||||
"""Test with_status filters by multiple statuses."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
operating = ParkFactory(status="OPERATING")
|
||||
closed_temp = ParkFactory(status="CLOSED_TEMP")
|
||||
closed_perm = ParkFactory(status="CLOSED_PERM")
|
||||
|
||||
result = Park.objects.get_queryset().with_status(status=["CLOSED_TEMP", "CLOSED_PERM"])
|
||||
|
||||
assert operating not in result
|
||||
assert closed_temp in result
|
||||
assert closed_perm in result
|
||||
|
||||
def test__operating__filters_operating_status(self):
|
||||
"""Test operating filters for OPERATING status."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
operating = ParkFactory(status="OPERATING")
|
||||
closed = ParkFactory(status="CLOSED_PERM")
|
||||
|
||||
result = Park.objects.operating()
|
||||
|
||||
assert operating in result
|
||||
assert closed not in result
|
||||
|
||||
def test__closed__filters_closed_statuses(self):
|
||||
"""Test closed filters for closed statuses."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
operating = ParkFactory(status="OPERATING")
|
||||
closed_temp = ParkFactory(status="CLOSED_TEMP")
|
||||
closed_perm = ParkFactory(status="CLOSED_PERM")
|
||||
|
||||
result = Park.objects.closed()
|
||||
|
||||
assert operating not in result
|
||||
assert closed_temp in result
|
||||
assert closed_perm in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestTimestampedQuerySet(TestCase):
|
||||
"""Tests for TimestampedQuerySet."""
|
||||
|
||||
def test__by_creation_date_descending__orders_newest_first(self):
|
||||
"""Test by_creation_date with descending orders newest first."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
park1 = ParkFactory()
|
||||
park2 = ParkFactory()
|
||||
|
||||
result = list(Park.objects.get_queryset().by_creation_date(descending=True))
|
||||
|
||||
# Most recently created should be first
|
||||
assert result[0] == park2
|
||||
assert result[1] == park1
|
||||
|
||||
def test__by_creation_date_ascending__orders_oldest_first(self):
|
||||
"""Test by_creation_date with ascending orders oldest first."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
park1 = ParkFactory()
|
||||
park2 = ParkFactory()
|
||||
|
||||
result = list(Park.objects.get_queryset().by_creation_date(descending=False))
|
||||
|
||||
# Oldest should be first
|
||||
assert result[0] == park1
|
||||
assert result[1] == park2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBaseManager(TestCase):
|
||||
"""Tests for BaseManager."""
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
# BaseManager's active method should work
|
||||
result = User.objects.filter(is_active=True)
|
||||
assert user in result
|
||||
|
||||
def test__recent__delegates_to_queryset(self):
|
||||
"""Test recent method delegates to queryset."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
park = ParkFactory()
|
||||
|
||||
result = Park.objects.recent(days=30)
|
||||
assert park in result
|
||||
|
||||
def test__search__delegates_to_queryset(self):
|
||||
"""Test search method delegates to queryset."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
park = ParkFactory(name="Unique Name")
|
||||
|
||||
result = Park.objects.search(query="Unique")
|
||||
assert park in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestStatusManager(TestCase):
|
||||
"""Tests for StatusManager."""
|
||||
|
||||
def test__operating__delegates_to_queryset(self):
|
||||
"""Test operating method delegates to queryset."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
operating = ParkFactory(status="OPERATING")
|
||||
|
||||
result = Park.objects.operating()
|
||||
assert operating in result
|
||||
|
||||
def test__closed__delegates_to_queryset(self):
|
||||
"""Test closed method delegates to queryset."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
closed = ParkFactory(status="CLOSED_PERM")
|
||||
|
||||
result = Park.objects.closed()
|
||||
assert closed in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReviewableManager(TestCase):
|
||||
"""Tests for ReviewableManager."""
|
||||
|
||||
def test__with_review_stats__delegates_to_queryset(self):
|
||||
"""Test with_review_stats method delegates to queryset."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
park = ParkFactory()
|
||||
|
||||
result = Park.objects.with_review_stats()
|
||||
assert park in result
|
||||
|
||||
def test__highly_rated__delegates_to_queryset(self):
|
||||
"""Test highly_rated method delegates to queryset."""
|
||||
from apps.parks.models import Park
|
||||
|
||||
park = ParkFactory()
|
||||
user = UserFactory()
|
||||
ParkReviewFactory(park=park, user=user, is_published=True, rating=9)
|
||||
|
||||
result = Park.objects.highly_rated(min_rating=8.0)
|
||||
assert park in result
|
||||
381
backend/tests/managers/test_park_managers.py
Normal file
381
backend/tests/managers/test_park_managers.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Tests for Park managers and querysets.
|
||||
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from apps.parks.models import Park, ParkArea, ParkReview, Company
|
||||
from apps.parks.managers import (
|
||||
ParkQuerySet,
|
||||
ParkManager,
|
||||
ParkAreaQuerySet,
|
||||
ParkAreaManager,
|
||||
ParkReviewQuerySet,
|
||||
ParkReviewManager,
|
||||
CompanyQuerySet,
|
||||
CompanyManager,
|
||||
)
|
||||
|
||||
from tests.factories import (
|
||||
ParkFactory,
|
||||
ParkAreaFactory,
|
||||
ParkReviewFactory,
|
||||
RideFactory,
|
||||
CoasterFactory,
|
||||
UserFactory,
|
||||
OperatorCompanyFactory,
|
||||
ManufacturerCompanyFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkQuerySet(TestCase):
|
||||
"""Tests for ParkQuerySet."""
|
||||
|
||||
def test__with_complete_stats__annotates_ride_counts(self):
|
||||
"""Test with_complete_stats adds ride count annotations."""
|
||||
park = ParkFactory()
|
||||
RideFactory(park=park, category="TR")
|
||||
RideFactory(park=park, category="TR")
|
||||
CoasterFactory(park=park, category="RC")
|
||||
|
||||
result = Park.objects.with_complete_stats().get(pk=park.pk)
|
||||
|
||||
assert result.ride_count_calculated == 3
|
||||
assert result.coaster_count_calculated == 1
|
||||
|
||||
def test__with_complete_stats__annotates_review_stats(self):
|
||||
"""Test with_complete_stats adds review statistics."""
|
||||
park = ParkFactory()
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
ParkReviewFactory(park=park, user=user1, is_published=True, rating=8)
|
||||
ParkReviewFactory(park=park, user=user2, is_published=True, rating=6)
|
||||
|
||||
result = Park.objects.with_complete_stats().get(pk=park.pk)
|
||||
|
||||
assert result.review_count == 2
|
||||
assert result.average_rating_calculated == 7.0
|
||||
|
||||
def test__with_complete_stats__excludes_unpublished_reviews(self):
|
||||
"""Test review stats exclude unpublished reviews."""
|
||||
park = ParkFactory()
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
ParkReviewFactory(park=park, user=user1, is_published=True, rating=10)
|
||||
ParkReviewFactory(park=park, user=user2, is_published=False, rating=2)
|
||||
|
||||
result = Park.objects.with_complete_stats().get(pk=park.pk)
|
||||
|
||||
assert result.review_count == 1
|
||||
assert result.average_rating_calculated == 10.0
|
||||
|
||||
def test__optimized_for_list__returns_prefetched_data(self):
|
||||
"""Test optimized_for_list prefetches related data."""
|
||||
ParkFactory()
|
||||
ParkFactory()
|
||||
|
||||
queryset = Park.objects.optimized_for_list()
|
||||
|
||||
# Should have prefetch cache populated
|
||||
assert queryset.count() == 2
|
||||
|
||||
def test__by_operator__filters_by_operator_id(self):
|
||||
"""Test by_operator filters parks by operator."""
|
||||
operator = OperatorCompanyFactory()
|
||||
other_operator = OperatorCompanyFactory()
|
||||
park1 = ParkFactory(operator=operator)
|
||||
park2 = ParkFactory(operator=other_operator)
|
||||
|
||||
result = Park.objects.by_operator(operator_id=operator.pk)
|
||||
|
||||
assert park1 in result
|
||||
assert park2 not in result
|
||||
|
||||
def test__by_property_owner__filters_by_owner_id(self):
|
||||
"""Test by_property_owner filters parks by property owner."""
|
||||
owner = OperatorCompanyFactory()
|
||||
park1 = ParkFactory(property_owner=owner)
|
||||
park2 = ParkFactory()
|
||||
|
||||
result = Park.objects.get_queryset().by_property_owner(owner_id=owner.pk)
|
||||
|
||||
assert park1 in result
|
||||
assert park2 not in result
|
||||
|
||||
def test__with_minimum_coasters__filters_by_coaster_count(self):
|
||||
"""Test with_minimum_coasters filters parks with enough coasters."""
|
||||
park1 = ParkFactory()
|
||||
park2 = ParkFactory()
|
||||
|
||||
# Add 5 coasters to park1
|
||||
for _ in range(5):
|
||||
CoasterFactory(park=park1)
|
||||
|
||||
# Add only 2 coasters to park2
|
||||
for _ in range(2):
|
||||
CoasterFactory(park=park2)
|
||||
|
||||
result = Park.objects.with_minimum_coasters(min_coasters=5)
|
||||
|
||||
assert park1 in result
|
||||
assert park2 not in result
|
||||
|
||||
def test__large_parks__filters_by_size(self):
|
||||
"""Test large_parks filters by minimum acreage."""
|
||||
large_park = ParkFactory(size_acres=200)
|
||||
small_park = ParkFactory(size_acres=50)
|
||||
|
||||
result = Park.objects.large_parks(min_acres=100)
|
||||
|
||||
assert large_park in result
|
||||
assert small_park not in result
|
||||
|
||||
def test__seasonal_parks__excludes_empty_operating_season(self):
|
||||
"""Test seasonal_parks excludes parks with empty operating_season."""
|
||||
seasonal_park = ParkFactory(operating_season="Summer only")
|
||||
year_round_park = ParkFactory(operating_season="")
|
||||
|
||||
result = Park.objects.get_queryset().seasonal_parks()
|
||||
|
||||
assert seasonal_park in result
|
||||
assert year_round_park not in result
|
||||
|
||||
def test__search_autocomplete__searches_by_name(self):
|
||||
"""Test search_autocomplete searches park names."""
|
||||
park1 = ParkFactory(name="Cedar Point")
|
||||
park2 = ParkFactory(name="Kings Island")
|
||||
|
||||
result = list(Park.objects.get_queryset().search_autocomplete(query="Cedar"))
|
||||
|
||||
assert park1 in result
|
||||
assert park2 not in result
|
||||
|
||||
def test__search_autocomplete__limits_results(self):
|
||||
"""Test search_autocomplete respects limit parameter."""
|
||||
for i in range(15):
|
||||
ParkFactory(name=f"Test Park {i}")
|
||||
|
||||
result = list(Park.objects.get_queryset().search_autocomplete(query="Test", limit=5))
|
||||
|
||||
assert len(result) == 5
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkManager(TestCase):
|
||||
"""Tests for ParkManager."""
|
||||
|
||||
def test__get_queryset__returns_park_queryset(self):
|
||||
"""Test get_queryset returns ParkQuerySet."""
|
||||
queryset = Park.objects.get_queryset()
|
||||
|
||||
assert isinstance(queryset, ParkQuerySet)
|
||||
|
||||
def test__operating__filters_operating_parks(self):
|
||||
"""Test operating filters for operating status."""
|
||||
operating = ParkFactory(status="OPERATING")
|
||||
closed = ParkFactory(status="CLOSED_PERM")
|
||||
|
||||
result = Park.objects.operating()
|
||||
|
||||
assert operating in result
|
||||
assert closed not in result
|
||||
|
||||
def test__closed__filters_closed_parks(self):
|
||||
"""Test closed filters for closed statuses."""
|
||||
operating = ParkFactory(status="OPERATING")
|
||||
closed_temp = ParkFactory(status="CLOSED_TEMP")
|
||||
closed_perm = ParkFactory(status="CLOSED_PERM")
|
||||
|
||||
result = Park.objects.closed()
|
||||
|
||||
assert operating not in result
|
||||
assert closed_temp in result
|
||||
assert closed_perm in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkAreaQuerySet(TestCase):
|
||||
"""Tests for ParkAreaQuerySet."""
|
||||
|
||||
def test__with_ride_counts__annotates_ride_count(self):
|
||||
"""Test with_ride_counts adds ride count annotation."""
|
||||
park = ParkFactory()
|
||||
area = ParkAreaFactory(park=park)
|
||||
RideFactory(park=park, park_area=area)
|
||||
RideFactory(park=park, park_area=area)
|
||||
CoasterFactory(park=park, park_area=area)
|
||||
|
||||
result = ParkArea.objects.with_ride_counts().get(pk=area.pk)
|
||||
|
||||
assert result.ride_count == 3
|
||||
assert result.coaster_count == 1
|
||||
|
||||
def test__by_park__filters_by_park_id(self):
|
||||
"""Test by_park filters areas by park."""
|
||||
park1 = ParkFactory()
|
||||
park2 = ParkFactory()
|
||||
area1 = ParkAreaFactory(park=park1)
|
||||
area2 = ParkAreaFactory(park=park2)
|
||||
|
||||
result = ParkArea.objects.by_park(park_id=park1.pk)
|
||||
|
||||
assert area1 in result
|
||||
assert area2 not in result
|
||||
|
||||
def test__with_rides__filters_areas_with_rides(self):
|
||||
"""Test with_rides filters areas that have rides."""
|
||||
park = ParkFactory()
|
||||
area_with_rides = ParkAreaFactory(park=park)
|
||||
area_without_rides = ParkAreaFactory(park=park)
|
||||
RideFactory(park=park, park_area=area_with_rides)
|
||||
|
||||
result = ParkArea.objects.get_queryset().with_rides()
|
||||
|
||||
assert area_with_rides in result
|
||||
assert area_without_rides not in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkReviewQuerySet(TestCase):
|
||||
"""Tests for ParkReviewQuerySet."""
|
||||
|
||||
def test__for_park__filters_by_park_id(self):
|
||||
"""Test for_park filters reviews by park."""
|
||||
park1 = ParkFactory()
|
||||
park2 = ParkFactory()
|
||||
user = UserFactory()
|
||||
review1 = ParkReviewFactory(park=park1, user=user)
|
||||
user2 = UserFactory()
|
||||
review2 = ParkReviewFactory(park=park2, user=user2)
|
||||
|
||||
result = ParkReview.objects.for_park(park_id=park1.pk)
|
||||
|
||||
assert review1 in result
|
||||
assert review2 not in result
|
||||
|
||||
def test__by_user__filters_by_user_id(self):
|
||||
"""Test by_user filters reviews by user."""
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
review1 = ParkReviewFactory(user=user1)
|
||||
review2 = ParkReviewFactory(user=user2)
|
||||
|
||||
result = ParkReview.objects.get_queryset().by_user(user_id=user1.pk)
|
||||
|
||||
assert review1 in result
|
||||
assert review2 not in result
|
||||
|
||||
def test__by_rating_range__filters_by_rating(self):
|
||||
"""Test by_rating_range filters reviews by rating range."""
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
user3 = UserFactory()
|
||||
high_review = ParkReviewFactory(rating=9, user=user1)
|
||||
mid_review = ParkReviewFactory(rating=5, user=user2)
|
||||
low_review = ParkReviewFactory(rating=2, user=user3)
|
||||
|
||||
result = ParkReview.objects.by_rating_range(min_rating=7, max_rating=10)
|
||||
|
||||
assert high_review in result
|
||||
assert mid_review not in result
|
||||
assert low_review not in result
|
||||
|
||||
def test__moderation_required__filters_unpublished_or_unmoderated(self):
|
||||
"""Test moderation_required filters reviews needing moderation."""
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
published = ParkReviewFactory(is_published=True, user=user1)
|
||||
unpublished = ParkReviewFactory(is_published=False, user=user2)
|
||||
|
||||
result = ParkReview.objects.moderation_required()
|
||||
|
||||
# unpublished should definitely be in result
|
||||
assert unpublished in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCompanyQuerySet(TestCase):
|
||||
"""Tests for CompanyQuerySet."""
|
||||
|
||||
def test__operators__filters_operator_companies(self):
|
||||
"""Test operators filters for companies with OPERATOR role."""
|
||||
operator = OperatorCompanyFactory()
|
||||
manufacturer = ManufacturerCompanyFactory()
|
||||
|
||||
result = Company.objects.operators()
|
||||
|
||||
assert operator in result
|
||||
assert manufacturer not in result
|
||||
|
||||
def test__manufacturers__filters_manufacturer_companies(self):
|
||||
"""Test manufacturers filters for companies with MANUFACTURER role."""
|
||||
operator = OperatorCompanyFactory()
|
||||
manufacturer = ManufacturerCompanyFactory()
|
||||
|
||||
result = Company.objects.manufacturers()
|
||||
|
||||
assert manufacturer in result
|
||||
assert operator not in result
|
||||
|
||||
def test__with_park_counts__annotates_park_counts(self):
|
||||
"""Test with_park_counts adds park count annotations."""
|
||||
operator = OperatorCompanyFactory()
|
||||
ParkFactory(operator=operator)
|
||||
ParkFactory(operator=operator)
|
||||
ParkFactory(property_owner=operator)
|
||||
|
||||
result = Company.objects.get_queryset().with_park_counts().get(pk=operator.pk)
|
||||
|
||||
assert result.operated_parks_count == 2
|
||||
assert result.owned_parks_count == 1
|
||||
|
||||
def test__major_operators__filters_by_minimum_parks(self):
|
||||
"""Test major_operators filters by minimum park count."""
|
||||
major_operator = OperatorCompanyFactory()
|
||||
small_operator = OperatorCompanyFactory()
|
||||
|
||||
for _ in range(6):
|
||||
ParkFactory(operator=major_operator)
|
||||
for _ in range(2):
|
||||
ParkFactory(operator=small_operator)
|
||||
|
||||
result = Company.objects.major_operators(min_parks=5)
|
||||
|
||||
assert major_operator in result
|
||||
assert small_operator not in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCompanyManager(TestCase):
|
||||
"""Tests for CompanyManager."""
|
||||
|
||||
def test__manufacturers_with_ride_count__annotates_ride_count(self):
|
||||
"""Test manufacturers_with_ride_count adds ride count annotation."""
|
||||
manufacturer = ManufacturerCompanyFactory()
|
||||
RideFactory(manufacturer=manufacturer)
|
||||
RideFactory(manufacturer=manufacturer)
|
||||
RideFactory(manufacturer=manufacturer)
|
||||
|
||||
result = list(Company.objects.manufacturers_with_ride_count())
|
||||
mfr = next((c for c in result if c.pk == manufacturer.pk), None)
|
||||
|
||||
assert mfr is not None
|
||||
assert mfr.ride_count == 3
|
||||
|
||||
def test__operators_with_park_count__annotates_park_count(self):
|
||||
"""Test operators_with_park_count adds park count annotation."""
|
||||
operator = OperatorCompanyFactory()
|
||||
ParkFactory(operator=operator)
|
||||
ParkFactory(operator=operator)
|
||||
|
||||
result = list(Company.objects.operators_with_park_count())
|
||||
op = next((c for c in result if c.pk == operator.pk), None)
|
||||
|
||||
assert op is not None
|
||||
assert op.operated_parks_count == 2
|
||||
408
backend/tests/managers/test_ride_managers.py
Normal file
408
backend/tests/managers/test_ride_managers.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
Tests for Ride managers and querysets.
|
||||
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.rides.models import Ride, RideModel, RideReview
|
||||
from apps.rides.managers import (
|
||||
RideQuerySet,
|
||||
RideManager,
|
||||
RideModelQuerySet,
|
||||
RideModelManager,
|
||||
RideReviewQuerySet,
|
||||
RideReviewManager,
|
||||
RollerCoasterStatsQuerySet,
|
||||
RollerCoasterStatsManager,
|
||||
)
|
||||
|
||||
from tests.factories import (
|
||||
RideFactory,
|
||||
CoasterFactory,
|
||||
ParkFactory,
|
||||
RideModelFactory,
|
||||
RideReviewFactory,
|
||||
UserFactory,
|
||||
ManufacturerCompanyFactory,
|
||||
DesignerCompanyFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideQuerySet(TestCase):
|
||||
"""Tests for RideQuerySet."""
|
||||
|
||||
def test__by_category__single_category__filters_correctly(self):
|
||||
"""Test by_category filters by single category."""
|
||||
coaster = RideFactory(category="RC")
|
||||
water_ride = RideFactory(category="WR")
|
||||
|
||||
result = Ride.objects.get_queryset().by_category(category="RC")
|
||||
|
||||
assert coaster in result
|
||||
assert water_ride not in result
|
||||
|
||||
def test__by_category__multiple_categories__filters_correctly(self):
|
||||
"""Test by_category filters by multiple categories."""
|
||||
rc = RideFactory(category="RC")
|
||||
wc = RideFactory(category="WC")
|
||||
tr = RideFactory(category="TR")
|
||||
|
||||
result = Ride.objects.get_queryset().by_category(category=["RC", "WC"])
|
||||
|
||||
assert rc in result
|
||||
assert wc in result
|
||||
assert tr not in result
|
||||
|
||||
def test__coasters__filters_roller_coasters(self):
|
||||
"""Test coasters filters for RC and WC categories."""
|
||||
steel = RideFactory(category="RC")
|
||||
wooden = RideFactory(category="WC")
|
||||
thrill = RideFactory(category="TR")
|
||||
|
||||
result = Ride.objects.coasters()
|
||||
|
||||
assert steel in result
|
||||
assert wooden in result
|
||||
assert thrill not in result
|
||||
|
||||
def test__thrill_rides__filters_thrill_categories(self):
|
||||
"""Test thrill_rides filters for thrill ride categories."""
|
||||
coaster = RideFactory(category="RC")
|
||||
flat_ride = RideFactory(category="FR")
|
||||
family = RideFactory(category="DR") # Dark ride
|
||||
|
||||
result = Ride.objects.thrill_rides()
|
||||
|
||||
assert coaster in result
|
||||
assert flat_ride in result
|
||||
assert family not in result
|
||||
|
||||
def test__family_friendly__filters_by_height_requirement(self):
|
||||
"""Test family_friendly filters by max height requirement."""
|
||||
family_ride = RideFactory(min_height_in=36)
|
||||
thrill_ride = RideFactory(min_height_in=54)
|
||||
no_restriction = RideFactory(min_height_in=None)
|
||||
|
||||
result = Ride.objects.family_friendly(max_height_requirement=42)
|
||||
|
||||
assert family_ride in result
|
||||
assert no_restriction in result
|
||||
assert thrill_ride not in result
|
||||
|
||||
def test__by_park__filters_by_park_id(self):
|
||||
"""Test by_park filters rides by park."""
|
||||
park1 = ParkFactory()
|
||||
park2 = ParkFactory()
|
||||
ride1 = RideFactory(park=park1)
|
||||
ride2 = RideFactory(park=park2)
|
||||
|
||||
result = Ride.objects.by_park(park_id=park1.pk)
|
||||
|
||||
assert ride1 in result
|
||||
assert ride2 not in result
|
||||
|
||||
def test__by_manufacturer__filters_by_manufacturer_id(self):
|
||||
"""Test by_manufacturer filters by manufacturer."""
|
||||
mfr1 = ManufacturerCompanyFactory()
|
||||
mfr2 = ManufacturerCompanyFactory()
|
||||
ride1 = RideFactory(manufacturer=mfr1)
|
||||
ride2 = RideFactory(manufacturer=mfr2)
|
||||
|
||||
result = Ride.objects.get_queryset().by_manufacturer(manufacturer_id=mfr1.pk)
|
||||
|
||||
assert ride1 in result
|
||||
assert ride2 not in result
|
||||
|
||||
def test__by_designer__filters_by_designer_id(self):
|
||||
"""Test by_designer filters by designer."""
|
||||
designer1 = DesignerCompanyFactory()
|
||||
designer2 = DesignerCompanyFactory()
|
||||
ride1 = RideFactory(designer=designer1)
|
||||
ride2 = RideFactory(designer=designer2)
|
||||
|
||||
result = Ride.objects.get_queryset().by_designer(designer_id=designer1.pk)
|
||||
|
||||
assert ride1 in result
|
||||
assert ride2 not in result
|
||||
|
||||
def test__with_capacity_info__annotates_capacity_data(self):
|
||||
"""Test with_capacity_info adds capacity annotations."""
|
||||
ride = RideFactory(capacity_per_hour=1500, ride_duration_seconds=180)
|
||||
|
||||
result = Ride.objects.get_queryset().with_capacity_info().get(pk=ride.pk)
|
||||
|
||||
assert result.estimated_daily_capacity == 15000 # 1500 * 10
|
||||
assert result.duration_minutes == 3.0 # 180 / 60
|
||||
|
||||
def test__high_capacity__filters_by_minimum_capacity(self):
|
||||
"""Test high_capacity filters by minimum capacity."""
|
||||
high_cap = RideFactory(capacity_per_hour=2000)
|
||||
low_cap = RideFactory(capacity_per_hour=500)
|
||||
|
||||
result = Ride.objects.high_capacity(min_capacity=1000)
|
||||
|
||||
assert high_cap in result
|
||||
assert low_cap not in result
|
||||
|
||||
def test__optimized_for_list__returns_prefetched_data(self):
|
||||
"""Test optimized_for_list prefetches related data."""
|
||||
RideFactory()
|
||||
RideFactory()
|
||||
|
||||
queryset = Ride.objects.optimized_for_list()
|
||||
|
||||
# Should return results with prefetched data
|
||||
assert queryset.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideManager(TestCase):
|
||||
"""Tests for RideManager."""
|
||||
|
||||
def test__get_queryset__returns_ride_queryset(self):
|
||||
"""Test get_queryset returns RideQuerySet."""
|
||||
queryset = Ride.objects.get_queryset()
|
||||
|
||||
assert isinstance(queryset, RideQuerySet)
|
||||
|
||||
def test__operating__filters_operating_rides(self):
|
||||
"""Test operating filters for operating status."""
|
||||
operating = RideFactory(status="OPERATING")
|
||||
closed = RideFactory(status="CLOSED_PERM")
|
||||
|
||||
result = Ride.objects.operating()
|
||||
|
||||
assert operating in result
|
||||
assert closed not in result
|
||||
|
||||
def test__coasters__delegates_to_queryset(self):
|
||||
"""Test coasters method delegates to queryset."""
|
||||
coaster = CoasterFactory(category="RC")
|
||||
thrill = RideFactory(category="TR")
|
||||
|
||||
result = Ride.objects.coasters()
|
||||
|
||||
assert coaster in result
|
||||
assert thrill not in result
|
||||
|
||||
def test__with_coaster_stats__prefetches_stats(self):
|
||||
"""Test with_coaster_stats prefetches coaster_stats."""
|
||||
ride = CoasterFactory()
|
||||
|
||||
queryset = Ride.objects.with_coaster_stats()
|
||||
|
||||
assert ride in queryset
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideModelQuerySet(TestCase):
|
||||
"""Tests for RideModelQuerySet."""
|
||||
|
||||
def test__by_manufacturer__filters_by_manufacturer_id(self):
|
||||
"""Test by_manufacturer filters ride models by manufacturer."""
|
||||
mfr1 = ManufacturerCompanyFactory()
|
||||
mfr2 = ManufacturerCompanyFactory()
|
||||
model1 = RideModelFactory(manufacturer=mfr1)
|
||||
model2 = RideModelFactory(manufacturer=mfr2)
|
||||
|
||||
result = RideModel.objects.by_manufacturer(manufacturer_id=mfr1.pk)
|
||||
|
||||
assert model1 in result
|
||||
assert model2 not in result
|
||||
|
||||
def test__with_ride_counts__annotates_ride_counts(self):
|
||||
"""Test with_ride_counts adds ride count annotation."""
|
||||
model = RideModelFactory()
|
||||
RideFactory(ride_model=model, status="OPERATING")
|
||||
RideFactory(ride_model=model, status="OPERATING")
|
||||
RideFactory(ride_model=model, status="CLOSED_PERM")
|
||||
|
||||
result = RideModel.objects.get_queryset().with_ride_counts().get(pk=model.pk)
|
||||
|
||||
assert result.ride_count == 3
|
||||
assert result.operating_rides_count == 2
|
||||
|
||||
def test__popular_models__filters_by_minimum_installations(self):
|
||||
"""Test popular_models filters by minimum ride count."""
|
||||
popular = RideModelFactory()
|
||||
unpopular = RideModelFactory()
|
||||
|
||||
for _ in range(6):
|
||||
RideFactory(ride_model=popular)
|
||||
for _ in range(2):
|
||||
RideFactory(ride_model=unpopular)
|
||||
|
||||
result = RideModel.objects.popular_models(min_installations=5)
|
||||
|
||||
assert popular in result
|
||||
assert unpopular not in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideModelManager(TestCase):
|
||||
"""Tests for RideModelManager."""
|
||||
|
||||
def test__get_queryset__returns_ride_model_queryset(self):
|
||||
"""Test get_queryset returns RideModelQuerySet."""
|
||||
queryset = RideModel.objects.get_queryset()
|
||||
|
||||
assert isinstance(queryset, RideModelQuerySet)
|
||||
|
||||
def test__by_manufacturer__delegates_to_queryset(self):
|
||||
"""Test by_manufacturer delegates to queryset."""
|
||||
mfr = ManufacturerCompanyFactory()
|
||||
model = RideModelFactory(manufacturer=mfr)
|
||||
|
||||
result = RideModel.objects.by_manufacturer(manufacturer_id=mfr.pk)
|
||||
|
||||
assert model in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideReviewQuerySet(TestCase):
|
||||
"""Tests for RideReviewQuerySet."""
|
||||
|
||||
def test__for_ride__filters_by_ride_id(self):
|
||||
"""Test for_ride filters reviews by ride."""
|
||||
ride1 = RideFactory()
|
||||
ride2 = RideFactory()
|
||||
user = UserFactory()
|
||||
review1 = RideReviewFactory(ride=ride1, user=user)
|
||||
user2 = UserFactory()
|
||||
review2 = RideReviewFactory(ride=ride2, user=user2)
|
||||
|
||||
result = RideReview.objects.for_ride(ride_id=ride1.pk)
|
||||
|
||||
assert review1 in result
|
||||
assert review2 not in result
|
||||
|
||||
def test__by_user__filters_by_user_id(self):
|
||||
"""Test by_user filters reviews by user."""
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
review1 = RideReviewFactory(user=user1)
|
||||
review2 = RideReviewFactory(user=user2)
|
||||
|
||||
result = RideReview.objects.get_queryset().by_user(user_id=user1.pk)
|
||||
|
||||
assert review1 in result
|
||||
assert review2 not in result
|
||||
|
||||
def test__by_rating_range__filters_by_rating(self):
|
||||
"""Test by_rating_range filters by rating range."""
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
user3 = UserFactory()
|
||||
high = RideReviewFactory(rating=9, user=user1)
|
||||
mid = RideReviewFactory(rating=5, user=user2)
|
||||
low = RideReviewFactory(rating=2, user=user3)
|
||||
|
||||
result = RideReview.objects.by_rating_range(min_rating=7, max_rating=10)
|
||||
|
||||
assert high in result
|
||||
assert mid not in result
|
||||
assert low not in result
|
||||
|
||||
def test__optimized_for_display__selects_related(self):
|
||||
"""Test optimized_for_display selects related data."""
|
||||
review = RideReviewFactory()
|
||||
|
||||
queryset = RideReview.objects.get_queryset().optimized_for_display()
|
||||
|
||||
# Should include the review
|
||||
assert review in queryset
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideReviewManager(TestCase):
|
||||
"""Tests for RideReviewManager."""
|
||||
|
||||
def test__get_queryset__returns_ride_review_queryset(self):
|
||||
"""Test get_queryset returns RideReviewQuerySet."""
|
||||
queryset = RideReview.objects.get_queryset()
|
||||
|
||||
assert isinstance(queryset, RideReviewQuerySet)
|
||||
|
||||
def test__for_ride__delegates_to_queryset(self):
|
||||
"""Test for_ride delegates to queryset."""
|
||||
ride = RideFactory()
|
||||
user = UserFactory()
|
||||
review = RideReviewFactory(ride=ride, user=user)
|
||||
|
||||
result = RideReview.objects.for_ride(ride_id=ride.pk)
|
||||
|
||||
assert review in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideQuerySetStatusMethods(TestCase):
|
||||
"""Tests for status-related RideQuerySet methods."""
|
||||
|
||||
def test__operating__filters_operating_rides(self):
|
||||
"""Test operating filters for OPERATING status."""
|
||||
operating = RideFactory(status="OPERATING")
|
||||
sbno = RideFactory(status="SBNO")
|
||||
closed = RideFactory(status="CLOSED_PERM")
|
||||
|
||||
result = Ride.objects.operating()
|
||||
|
||||
assert operating in result
|
||||
assert sbno not in result
|
||||
assert closed not in result
|
||||
|
||||
def test__closed__filters_closed_rides(self):
|
||||
"""Test closed filters for closed statuses."""
|
||||
operating = RideFactory(status="OPERATING")
|
||||
closed_temp = RideFactory(status="CLOSED_TEMP")
|
||||
closed_perm = RideFactory(status="CLOSED_PERM")
|
||||
|
||||
result = Ride.objects.closed()
|
||||
|
||||
assert operating not in result
|
||||
assert closed_temp in result
|
||||
assert closed_perm in result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideQuerySetReviewMethods(TestCase):
|
||||
"""Tests for review-related RideQuerySet methods."""
|
||||
|
||||
def test__with_review_stats__annotates_review_data(self):
|
||||
"""Test with_review_stats adds review statistics."""
|
||||
ride = RideFactory()
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
RideReviewFactory(ride=ride, user=user1, is_published=True, rating=8)
|
||||
RideReviewFactory(ride=ride, user=user2, is_published=True, rating=6)
|
||||
|
||||
result = Ride.objects.get_queryset().with_review_stats().get(pk=ride.pk)
|
||||
|
||||
assert result.review_count == 2
|
||||
assert result.average_rating == 7.0
|
||||
|
||||
def test__highly_rated__filters_by_minimum_rating(self):
|
||||
"""Test highly_rated filters by minimum average rating."""
|
||||
ride1 = RideFactory()
|
||||
ride2 = RideFactory()
|
||||
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
# High rated ride
|
||||
RideReviewFactory(ride=ride1, user=user1, is_published=True, rating=9)
|
||||
RideReviewFactory(ride=ride1, user=user2, is_published=True, rating=10)
|
||||
|
||||
user3 = UserFactory()
|
||||
user4 = UserFactory()
|
||||
# Low rated ride
|
||||
RideReviewFactory(ride=ride2, user=user3, is_published=True, rating=4)
|
||||
RideReviewFactory(ride=ride2, user=user4, is_published=True, rating=5)
|
||||
|
||||
result = Ride.objects.get_queryset().highly_rated(min_rating=8.0)
|
||||
|
||||
assert ride1 in result
|
||||
assert ride2 not in result
|
||||
5
backend/tests/middleware/__init__.py
Normal file
5
backend/tests/middleware/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Middleware tests.
|
||||
|
||||
This module contains tests for custom middleware classes.
|
||||
"""
|
||||
368
backend/tests/middleware/test_contract_validation_middleware.py
Normal file
368
backend/tests/middleware/test_contract_validation_middleware.py
Normal file
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
Tests for ContractValidationMiddleware.
|
||||
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase, RequestFactory, override_settings
|
||||
from django.http import JsonResponse, HttpResponse
|
||||
|
||||
from apps.api.v1.middleware import (
|
||||
ContractValidationMiddleware,
|
||||
ContractValidationSettings,
|
||||
)
|
||||
|
||||
|
||||
class TestContractValidationMiddlewareInit(TestCase):
|
||||
"""Tests for ContractValidationMiddleware initialization."""
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test__init__debug_true__enables_middleware(self):
|
||||
"""Test middleware is enabled when DEBUG=True."""
|
||||
get_response = Mock()
|
||||
middleware = ContractValidationMiddleware(get_response)
|
||||
|
||||
assert middleware.enabled is True
|
||||
|
||||
@override_settings(DEBUG=False)
|
||||
def test__init__debug_false__disables_middleware(self):
|
||||
"""Test middleware is disabled when DEBUG=False."""
|
||||
get_response = Mock()
|
||||
middleware = ContractValidationMiddleware(get_response)
|
||||
|
||||
assert middleware.enabled is False
|
||||
|
||||
|
||||
class TestContractValidationMiddlewareProcessResponse(TestCase):
|
||||
"""Tests for ContractValidationMiddleware.process_response."""
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.get_response = Mock()
|
||||
self.middleware = ContractValidationMiddleware(self.get_response)
|
||||
self.middleware.enabled = True
|
||||
|
||||
def test__process_response__non_api_path__skips_validation(self):
|
||||
"""Test process_response skips non-API paths."""
|
||||
request = self.factory.get("/some/path/")
|
||||
response = JsonResponse({"data": "value"})
|
||||
|
||||
result = self.middleware.process_response(request, response)
|
||||
|
||||
assert result == response
|
||||
|
||||
def test__process_response__non_json_response__skips_validation(self):
|
||||
"""Test process_response skips non-JSON responses."""
|
||||
request = self.factory.get("/api/v1/parks/")
|
||||
response = HttpResponse("HTML content")
|
||||
|
||||
result = self.middleware.process_response(request, response)
|
||||
|
||||
assert result == response
|
||||
|
||||
def test__process_response__error_status_code__skips_validation(self):
|
||||
"""Test process_response skips error responses."""
|
||||
request = self.factory.get("/api/v1/parks/")
|
||||
response = JsonResponse({"error": "Not found"}, status=404)
|
||||
|
||||
result = self.middleware.process_response(request, response)
|
||||
|
||||
assert result == response
|
||||
|
||||
@override_settings(DEBUG=False)
|
||||
def test__process_response__middleware_disabled__skips_validation(self):
|
||||
"""Test process_response skips when middleware is disabled."""
|
||||
self.middleware.enabled = False
|
||||
request = self.factory.get("/api/v1/parks/")
|
||||
response = JsonResponse({"data": "value"})
|
||||
|
||||
result = self.middleware.process_response(request, response)
|
||||
|
||||
assert result == response
|
||||
|
||||
|
||||
class TestContractValidationMiddlewareFilterValidation(TestCase):
|
||||
"""Tests for filter metadata validation."""
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.get_response = Mock()
|
||||
self.middleware = ContractValidationMiddleware(self.get_response)
|
||||
self.middleware.enabled = True
|
||||
|
||||
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
|
||||
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 = {
|
||||
"categorical": {
|
||||
"status": [
|
||||
{"value": "OPERATING", "label": "Operating", "count": 10},
|
||||
{"value": "CLOSED", "label": "Closed", "count": 5},
|
||||
]
|
||||
}
|
||||
}
|
||||
response = JsonResponse(valid_data)
|
||||
|
||||
self.middleware.process_response(request, response)
|
||||
|
||||
# Should not log CATEGORICAL_OPTION_IS_STRING
|
||||
for call in mock_log.call_args_list:
|
||||
assert "CATEGORICAL_OPTION_IS_STRING" not in str(call)
|
||||
|
||||
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
|
||||
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
|
||||
}
|
||||
}
|
||||
response = JsonResponse(invalid_data)
|
||||
|
||||
self.middleware.process_response(request, response)
|
||||
|
||||
# Should log CATEGORICAL_OPTION_IS_STRING violation
|
||||
mock_log.assert_called()
|
||||
call_args = [str(call) for call in mock_log.call_args_list]
|
||||
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
|
||||
):
|
||||
"""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'
|
||||
]
|
||||
}
|
||||
}
|
||||
response = JsonResponse(invalid_data)
|
||||
|
||||
self.middleware.process_response(request, response)
|
||||
|
||||
mock_log.assert_called()
|
||||
call_args = [str(call) for call in mock_log.call_args_list]
|
||||
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
|
||||
):
|
||||
"""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'
|
||||
]
|
||||
}
|
||||
}
|
||||
response = JsonResponse(invalid_data)
|
||||
|
||||
self.middleware.process_response(request, response)
|
||||
|
||||
mock_log.assert_called()
|
||||
call_args = [str(call) for call in mock_log.call_args_list]
|
||||
assert any("MISSING_LABEL_PROPERTY" in arg for arg in call_args)
|
||||
|
||||
|
||||
class TestContractValidationMiddlewareRangeValidation(TestCase):
|
||||
"""Tests for range filter validation."""
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.get_response = Mock()
|
||||
self.middleware = ContractValidationMiddleware(self.get_response)
|
||||
self.middleware.enabled = True
|
||||
|
||||
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
|
||||
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"}
|
||||
}
|
||||
}
|
||||
response = JsonResponse(valid_data)
|
||||
|
||||
self.middleware.process_response(request, response)
|
||||
|
||||
# Should not log RANGE_FILTER_NOT_OBJECT
|
||||
for call in mock_log.call_args_list:
|
||||
assert "RANGE_FILTER_NOT_OBJECT" not in str(call)
|
||||
|
||||
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
|
||||
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'
|
||||
}
|
||||
}
|
||||
response = JsonResponse(invalid_data)
|
||||
|
||||
self.middleware.process_response(request, response)
|
||||
|
||||
mock_log.assert_called()
|
||||
call_args = [str(call) for call in mock_log.call_args_list]
|
||||
assert any("MISSING_RANGE_PROPERTY" in arg for arg in call_args)
|
||||
|
||||
|
||||
class TestContractValidationMiddlewareHybridValidation(TestCase):
|
||||
"""Tests for hybrid response validation."""
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.get_response = Mock()
|
||||
self.middleware = ContractValidationMiddleware(self.get_response)
|
||||
self.middleware.enabled = True
|
||||
|
||||
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
|
||||
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": {}
|
||||
}
|
||||
response = JsonResponse(valid_data)
|
||||
|
||||
self.middleware.process_response(request, response)
|
||||
|
||||
# Should not log INVALID_STRATEGY_VALUE
|
||||
for call in mock_log.call_args_list:
|
||||
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
|
||||
):
|
||||
"""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": []
|
||||
}
|
||||
response = JsonResponse(invalid_data)
|
||||
|
||||
self.middleware.process_response(request, response)
|
||||
|
||||
mock_log.assert_called()
|
||||
call_args = [str(call) for call in mock_log.call_args_list]
|
||||
assert any("INVALID_STRATEGY_VALUE" in arg for arg in call_args)
|
||||
|
||||
|
||||
class TestContractValidationMiddlewarePaginationValidation(TestCase):
|
||||
"""Tests for pagination response validation."""
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.get_response = Mock()
|
||||
self.middleware = ContractValidationMiddleware(self.get_response)
|
||||
self.middleware.enabled = True
|
||||
|
||||
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
|
||||
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}]
|
||||
}
|
||||
response = JsonResponse(valid_data)
|
||||
|
||||
self.middleware.process_response(request, response)
|
||||
|
||||
# Should not log MISSING_PAGINATION_FIELD or RESULTS_NOT_ARRAY
|
||||
for call in mock_log.call_args_list:
|
||||
assert "MISSING_PAGINATION_FIELD" not in str(call)
|
||||
assert "RESULTS_NOT_ARRAY" not in str(call)
|
||||
|
||||
@patch.object(ContractValidationMiddleware, "_log_contract_violation")
|
||||
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
|
||||
}
|
||||
response = JsonResponse(invalid_data)
|
||||
|
||||
self.middleware.process_response(request, response)
|
||||
|
||||
mock_log.assert_called()
|
||||
call_args = [str(call) for call in mock_log.call_args_list]
|
||||
assert any("RESULTS_NOT_ARRAY" in arg for arg in call_args)
|
||||
|
||||
|
||||
class TestContractValidationSettings(TestCase):
|
||||
"""Tests for ContractValidationSettings."""
|
||||
|
||||
def test__should_validate_path__regular_api_path__returns_true(self):
|
||||
"""Test should_validate_path returns True for regular API paths."""
|
||||
result = ContractValidationSettings.should_validate_path("/api/v1/parks/")
|
||||
|
||||
assert result is True
|
||||
|
||||
def test__should_validate_path__docs_path__returns_false(self):
|
||||
"""Test should_validate_path returns False for docs paths."""
|
||||
result = ContractValidationSettings.should_validate_path("/api/docs/")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test__should_validate_path__schema_path__returns_false(self):
|
||||
"""Test should_validate_path returns False for schema paths."""
|
||||
result = ContractValidationSettings.should_validate_path("/api/schema/")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test__should_validate_path__auth_path__returns_false(self):
|
||||
"""Test should_validate_path returns False for auth paths."""
|
||||
result = ContractValidationSettings.should_validate_path("/api/v1/auth/login/")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestContractValidationMiddlewareViolationSuggestions(TestCase):
|
||||
"""Tests for violation suggestion messages."""
|
||||
|
||||
def setUp(self):
|
||||
self.get_response = Mock()
|
||||
self.middleware = ContractValidationMiddleware(self.get_response)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
assert "ensure_filter_option_format" in suggestion
|
||||
assert "object arrays" in suggestion
|
||||
|
||||
def test__get_violation_suggestion__missing_value__returns_suggestion(self):
|
||||
"""Test get_violation_suggestion returns suggestion for MISSING_VALUE_PROPERTY."""
|
||||
suggestion = self.middleware._get_violation_suggestion("MISSING_VALUE_PROPERTY")
|
||||
|
||||
assert "value" in suggestion
|
||||
assert "FilterOptionSerializer" in suggestion
|
||||
|
||||
def test__get_violation_suggestion__unknown_violation__returns_default(self):
|
||||
"""Test get_violation_suggestion returns default for unknown violation."""
|
||||
suggestion = self.middleware._get_violation_suggestion("UNKNOWN_VIOLATION_TYPE")
|
||||
|
||||
assert "TypeScript interfaces" in suggestion
|
||||
6
backend/tests/serializers/__init__.py
Normal file
6
backend/tests/serializers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Serializer tests.
|
||||
|
||||
This module contains tests for DRF serializers to verify
|
||||
validation, field mapping, and custom logic.
|
||||
"""
|
||||
514
backend/tests/serializers/test_account_serializers.py
Normal file
514
backend/tests/serializers/test_account_serializers.py
Normal file
@@ -0,0 +1,514 @@
|
||||
"""
|
||||
Tests for Account serializers.
|
||||
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase, RequestFactory
|
||||
|
||||
from apps.accounts.serializers import (
|
||||
UserSerializer,
|
||||
LoginSerializer,
|
||||
SignupSerializer,
|
||||
PasswordResetSerializer,
|
||||
PasswordChangeSerializer,
|
||||
SocialProviderSerializer,
|
||||
)
|
||||
|
||||
from apps.api.v1.accounts.serializers import (
|
||||
UserProfileCreateInputSerializer,
|
||||
UserProfileUpdateInputSerializer,
|
||||
UserProfileOutputSerializer,
|
||||
TopListCreateInputSerializer,
|
||||
TopListUpdateInputSerializer,
|
||||
TopListOutputSerializer,
|
||||
TopListItemCreateInputSerializer,
|
||||
TopListItemUpdateInputSerializer,
|
||||
TopListItemOutputSerializer,
|
||||
)
|
||||
|
||||
from tests.factories import (
|
||||
UserFactory,
|
||||
StaffUserFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserSerializer(TestCase):
|
||||
"""Tests for UserSerializer."""
|
||||
|
||||
def test__serialize__user__returns_expected_fields(self):
|
||||
"""Test serializing a user returns expected fields."""
|
||||
user = UserFactory()
|
||||
|
||||
serializer = UserSerializer(user)
|
||||
data = serializer.data
|
||||
|
||||
assert "id" in data
|
||||
assert "username" in data
|
||||
assert "email" in data
|
||||
assert "display_name" in data
|
||||
assert "date_joined" in data
|
||||
assert "is_active" in data
|
||||
assert "avatar_url" in data
|
||||
|
||||
def test__serialize__user_without_profile__returns_none_avatar(self):
|
||||
"""Test serializing user without profile returns None for avatar."""
|
||||
user = UserFactory()
|
||||
# Ensure no profile
|
||||
if hasattr(user, "profile"):
|
||||
user.profile.delete()
|
||||
|
||||
serializer = UserSerializer(user)
|
||||
data = serializer.data
|
||||
|
||||
assert data["avatar_url"] is None
|
||||
|
||||
def test__get_display_name__user_with_display_name__returns_display_name(self):
|
||||
"""Test get_display_name returns user's display name."""
|
||||
user = UserFactory()
|
||||
user.display_name = "John Doe"
|
||||
user.save()
|
||||
|
||||
serializer = UserSerializer(user)
|
||||
|
||||
# get_display_name calls the model method
|
||||
assert "display_name" in serializer.data
|
||||
|
||||
def test__meta__read_only_fields__includes_id_and_dates(self):
|
||||
"""Test Meta.read_only_fields includes id and date fields."""
|
||||
assert "id" in UserSerializer.Meta.read_only_fields
|
||||
assert "date_joined" in UserSerializer.Meta.read_only_fields
|
||||
assert "is_active" in UserSerializer.Meta.read_only_fields
|
||||
|
||||
|
||||
class TestLoginSerializer(TestCase):
|
||||
"""Tests for LoginSerializer."""
|
||||
|
||||
def test__validate__valid_credentials__returns_data(self):
|
||||
"""Test validation passes with valid credentials."""
|
||||
data = {
|
||||
"username": "testuser",
|
||||
"password": "testpassword123",
|
||||
}
|
||||
|
||||
serializer = LoginSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["username"] == "testuser"
|
||||
assert serializer.validated_data["password"] == "testpassword123"
|
||||
|
||||
def test__validate__email_as_username__returns_data(self):
|
||||
"""Test validation passes with email as username."""
|
||||
data = {
|
||||
"username": "user@example.com",
|
||||
"password": "testpassword123",
|
||||
}
|
||||
|
||||
serializer = LoginSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["username"] == "user@example.com"
|
||||
|
||||
def test__validate__missing_username__returns_error(self):
|
||||
"""Test validation fails with missing username."""
|
||||
data = {"password": "testpassword123"}
|
||||
|
||||
serializer = LoginSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "username" in serializer.errors
|
||||
|
||||
def test__validate__missing_password__returns_error(self):
|
||||
"""Test validation fails with missing password."""
|
||||
data = {"username": "testuser"}
|
||||
|
||||
serializer = LoginSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "password" in serializer.errors
|
||||
|
||||
def test__validate__empty_credentials__returns_error(self):
|
||||
"""Test validation fails with empty credentials."""
|
||||
data = {"username": "", "password": ""}
|
||||
|
||||
serializer = LoginSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSignupSerializer(TestCase):
|
||||
"""Tests for SignupSerializer."""
|
||||
|
||||
def test__validate__valid_data__returns_validated_data(self):
|
||||
"""Test validation passes with valid signup data."""
|
||||
data = {
|
||||
"username": "newuser",
|
||||
"email": "newuser@example.com",
|
||||
"display_name": "New User",
|
||||
"password": "SecurePass123!",
|
||||
"password_confirm": "SecurePass123!",
|
||||
}
|
||||
|
||||
serializer = SignupSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
def test__validate__mismatched_passwords__returns_error(self):
|
||||
"""Test validation fails with mismatched passwords."""
|
||||
data = {
|
||||
"username": "newuser",
|
||||
"email": "newuser@example.com",
|
||||
"display_name": "New User",
|
||||
"password": "SecurePass123!",
|
||||
"password_confirm": "DifferentPass456!",
|
||||
}
|
||||
|
||||
serializer = SignupSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "password_confirm" in serializer.errors
|
||||
|
||||
def test__validate_email__duplicate_email__returns_error(self):
|
||||
"""Test validation fails with duplicate email."""
|
||||
existing_user = UserFactory(email="existing@example.com")
|
||||
data = {
|
||||
"username": "newuser",
|
||||
"email": "existing@example.com",
|
||||
"display_name": "New User",
|
||||
"password": "SecurePass123!",
|
||||
"password_confirm": "SecurePass123!",
|
||||
}
|
||||
|
||||
serializer = SignupSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "email" in serializer.errors
|
||||
|
||||
def test__validate_email__case_insensitive__returns_error(self):
|
||||
"""Test email validation is case insensitive."""
|
||||
existing_user = UserFactory(email="existing@example.com")
|
||||
data = {
|
||||
"username": "newuser",
|
||||
"email": "EXISTING@EXAMPLE.COM",
|
||||
"display_name": "New User",
|
||||
"password": "SecurePass123!",
|
||||
"password_confirm": "SecurePass123!",
|
||||
}
|
||||
|
||||
serializer = SignupSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "email" in serializer.errors
|
||||
|
||||
def test__validate_username__duplicate_username__returns_error(self):
|
||||
"""Test validation fails with duplicate username."""
|
||||
existing_user = UserFactory(username="existinguser")
|
||||
data = {
|
||||
"username": "existinguser",
|
||||
"email": "new@example.com",
|
||||
"display_name": "New User",
|
||||
"password": "SecurePass123!",
|
||||
"password_confirm": "SecurePass123!",
|
||||
}
|
||||
|
||||
serializer = SignupSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "username" in serializer.errors
|
||||
|
||||
def test__validate__weak_password__returns_error(self):
|
||||
"""Test validation fails with weak password."""
|
||||
data = {
|
||||
"username": "newuser",
|
||||
"email": "newuser@example.com",
|
||||
"display_name": "New User",
|
||||
"password": "123", # Too weak
|
||||
"password_confirm": "123",
|
||||
}
|
||||
|
||||
serializer = SignupSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
# Password validation error could be in 'password' or 'non_field_errors'
|
||||
assert "password" in serializer.errors or "non_field_errors" in serializer.errors
|
||||
|
||||
def test__create__valid_data__creates_user(self):
|
||||
"""Test create method creates user correctly."""
|
||||
data = {
|
||||
"username": "createuser",
|
||||
"email": "createuser@example.com",
|
||||
"display_name": "Create User",
|
||||
"password": "SecurePass123!",
|
||||
"password_confirm": "SecurePass123!",
|
||||
}
|
||||
|
||||
serializer = SignupSerializer(data=data)
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
user = serializer.save()
|
||||
|
||||
assert user.username == "createuser"
|
||||
assert user.email == "createuser@example.com"
|
||||
assert user.check_password("SecurePass123!")
|
||||
|
||||
def test__meta__password_write_only__excludes_from_output(self):
|
||||
"""Test password field is write-only."""
|
||||
assert "password" in SignupSerializer.Meta.fields
|
||||
assert SignupSerializer.Meta.extra_kwargs.get("password", {}).get("write_only") is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPasswordResetSerializer(TestCase):
|
||||
"""Tests for PasswordResetSerializer."""
|
||||
|
||||
def test__validate__valid_email__returns_normalized_email(self):
|
||||
"""Test validation normalizes email."""
|
||||
user = UserFactory(email="test@example.com")
|
||||
data = {"email": " TEST@EXAMPLE.COM "}
|
||||
|
||||
serializer = PasswordResetSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["email"] == "test@example.com"
|
||||
|
||||
def test__validate__nonexistent_email__still_valid(self):
|
||||
"""Test validation passes with nonexistent email (security)."""
|
||||
data = {"email": "nonexistent@example.com"}
|
||||
|
||||
serializer = PasswordResetSerializer(data=data)
|
||||
|
||||
# Should pass validation to prevent email enumeration
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
def test__validate__existing_email__attaches_user(self):
|
||||
"""Test validation attaches user when email exists."""
|
||||
user = UserFactory(email="exists@example.com")
|
||||
data = {"email": "exists@example.com"}
|
||||
|
||||
serializer = PasswordResetSerializer(data=data)
|
||||
serializer.is_valid()
|
||||
|
||||
assert hasattr(serializer, "user")
|
||||
assert serializer.user == user
|
||||
|
||||
def test__validate__nonexistent_email__no_user_attached(self):
|
||||
"""Test validation doesn't attach user for nonexistent email."""
|
||||
data = {"email": "notfound@example.com"}
|
||||
|
||||
serializer = PasswordResetSerializer(data=data)
|
||||
serializer.is_valid()
|
||||
|
||||
assert not hasattr(serializer, "user")
|
||||
|
||||
@patch("apps.accounts.serializers.EmailService.send_email")
|
||||
def test__save__existing_user__sends_email(self, mock_send_email):
|
||||
"""Test save sends email for existing user."""
|
||||
user = UserFactory(email="reset@example.com")
|
||||
data = {"email": "reset@example.com"}
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.post("/password-reset/")
|
||||
|
||||
serializer = PasswordResetSerializer(data=data, context={"request": request})
|
||||
serializer.is_valid()
|
||||
serializer.save()
|
||||
|
||||
# Email should be sent
|
||||
mock_send_email.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPasswordChangeSerializer(TestCase):
|
||||
"""Tests for PasswordChangeSerializer."""
|
||||
|
||||
def test__validate__valid_data__returns_validated_data(self):
|
||||
"""Test validation passes with valid password change data."""
|
||||
user = UserFactory()
|
||||
user.set_password("OldPass123!")
|
||||
user.save()
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.post("/password-change/")
|
||||
request.user = user
|
||||
|
||||
data = {
|
||||
"old_password": "OldPass123!",
|
||||
"new_password": "NewSecurePass456!",
|
||||
"new_password_confirm": "NewSecurePass456!",
|
||||
}
|
||||
|
||||
serializer = PasswordChangeSerializer(data=data, context={"request": request})
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
def test__validate_old_password__incorrect__returns_error(self):
|
||||
"""Test validation fails with incorrect old password."""
|
||||
user = UserFactory()
|
||||
user.set_password("CorrectOldPass!")
|
||||
user.save()
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.post("/password-change/")
|
||||
request.user = user
|
||||
|
||||
data = {
|
||||
"old_password": "WrongOldPass!",
|
||||
"new_password": "NewSecurePass456!",
|
||||
"new_password_confirm": "NewSecurePass456!",
|
||||
}
|
||||
|
||||
serializer = PasswordChangeSerializer(data=data, context={"request": request})
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "old_password" in serializer.errors
|
||||
|
||||
def test__validate__mismatched_new_passwords__returns_error(self):
|
||||
"""Test validation fails with mismatched new passwords."""
|
||||
user = UserFactory()
|
||||
user.set_password("OldPass123!")
|
||||
user.save()
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.post("/password-change/")
|
||||
request.user = user
|
||||
|
||||
data = {
|
||||
"old_password": "OldPass123!",
|
||||
"new_password": "NewSecurePass456!",
|
||||
"new_password_confirm": "DifferentPass789!",
|
||||
}
|
||||
|
||||
serializer = PasswordChangeSerializer(data=data, context={"request": request})
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "new_password_confirm" in serializer.errors
|
||||
|
||||
def test__save__valid_data__changes_password(self):
|
||||
"""Test save changes the password."""
|
||||
user = UserFactory()
|
||||
user.set_password("OldPass123!")
|
||||
user.save()
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.post("/password-change/")
|
||||
request.user = user
|
||||
|
||||
data = {
|
||||
"old_password": "OldPass123!",
|
||||
"new_password": "NewSecurePass456!",
|
||||
"new_password_confirm": "NewSecurePass456!",
|
||||
}
|
||||
|
||||
serializer = PasswordChangeSerializer(data=data, context={"request": request})
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
serializer.save()
|
||||
|
||||
user.refresh_from_db()
|
||||
assert user.check_password("NewSecurePass456!")
|
||||
|
||||
|
||||
class TestSocialProviderSerializer(TestCase):
|
||||
"""Tests for SocialProviderSerializer."""
|
||||
|
||||
def test__validate__valid_provider__returns_data(self):
|
||||
"""Test validation passes with valid provider data."""
|
||||
data = {
|
||||
"id": "google",
|
||||
"name": "Google",
|
||||
"login_url": "https://accounts.google.com/oauth/login",
|
||||
}
|
||||
|
||||
serializer = SocialProviderSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["id"] == "google"
|
||||
assert serializer.validated_data["name"] == "Google"
|
||||
|
||||
def test__validate__invalid_url__returns_error(self):
|
||||
"""Test validation fails with invalid URL."""
|
||||
data = {
|
||||
"id": "invalid",
|
||||
"name": "Invalid Provider",
|
||||
"login_url": "not-a-valid-url",
|
||||
}
|
||||
|
||||
serializer = SocialProviderSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "login_url" in serializer.errors
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserProfileOutputSerializer(TestCase):
|
||||
"""Tests for UserProfileOutputSerializer."""
|
||||
|
||||
def test__serialize__profile__returns_expected_fields(self):
|
||||
"""Test serializing profile returns expected fields."""
|
||||
user = UserFactory()
|
||||
|
||||
# Create mock profile
|
||||
mock_profile = Mock()
|
||||
mock_profile.user = user
|
||||
mock_profile.avatar = None
|
||||
|
||||
serializer = UserProfileOutputSerializer(mock_profile)
|
||||
|
||||
# Should include user nested serializer
|
||||
assert "user" in serializer.data or serializer.data is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserProfileCreateInputSerializer(TestCase):
|
||||
"""Tests for UserProfileCreateInputSerializer."""
|
||||
|
||||
def test__meta__fields__includes_all_fields(self):
|
||||
"""Test Meta.fields is set to __all__."""
|
||||
assert UserProfileCreateInputSerializer.Meta.fields == "__all__"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserProfileUpdateInputSerializer(TestCase):
|
||||
"""Tests for UserProfileUpdateInputSerializer."""
|
||||
|
||||
def test__meta__user_read_only(self):
|
||||
"""Test user field is read-only for updates."""
|
||||
extra_kwargs = UserProfileUpdateInputSerializer.Meta.extra_kwargs
|
||||
assert extra_kwargs.get("user", {}).get("read_only") is True
|
||||
|
||||
|
||||
class TestTopListCreateInputSerializer(TestCase):
|
||||
"""Tests for TopListCreateInputSerializer."""
|
||||
|
||||
def test__meta__fields__includes_all_fields(self):
|
||||
"""Test Meta.fields is set to __all__."""
|
||||
assert TopListCreateInputSerializer.Meta.fields == "__all__"
|
||||
|
||||
|
||||
class TestTopListUpdateInputSerializer(TestCase):
|
||||
"""Tests for TopListUpdateInputSerializer."""
|
||||
|
||||
def test__meta__user_read_only(self):
|
||||
"""Test user field is read-only for updates."""
|
||||
extra_kwargs = TopListUpdateInputSerializer.Meta.extra_kwargs
|
||||
assert extra_kwargs.get("user", {}).get("read_only") is True
|
||||
|
||||
|
||||
class TestTopListItemCreateInputSerializer(TestCase):
|
||||
"""Tests for TopListItemCreateInputSerializer."""
|
||||
|
||||
def test__meta__fields__includes_all_fields(self):
|
||||
"""Test Meta.fields is set to __all__."""
|
||||
assert TopListItemCreateInputSerializer.Meta.fields == "__all__"
|
||||
|
||||
|
||||
class TestTopListItemUpdateInputSerializer(TestCase):
|
||||
"""Tests for TopListItemUpdateInputSerializer."""
|
||||
|
||||
def test__meta__top_list_not_read_only(self):
|
||||
"""Test top_list field is not read-only for updates."""
|
||||
extra_kwargs = TopListItemUpdateInputSerializer.Meta.extra_kwargs
|
||||
assert extra_kwargs.get("top_list", {}).get("read_only") is False
|
||||
477
backend/tests/serializers/test_park_serializers.py
Normal file
477
backend/tests/serializers/test_park_serializers.py
Normal file
@@ -0,0 +1,477 @@
|
||||
"""
|
||||
Tests for Park serializers.
|
||||
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.api.v1.parks.serializers import (
|
||||
ParkPhotoOutputSerializer,
|
||||
ParkPhotoCreateInputSerializer,
|
||||
ParkPhotoUpdateInputSerializer,
|
||||
ParkPhotoListOutputSerializer,
|
||||
ParkPhotoApprovalInputSerializer,
|
||||
ParkPhotoStatsOutputSerializer,
|
||||
ParkPhotoSerializer,
|
||||
HybridParkSerializer,
|
||||
ParkSerializer,
|
||||
)
|
||||
|
||||
from tests.factories import (
|
||||
ParkFactory,
|
||||
ParkPhotoFactory,
|
||||
UserFactory,
|
||||
CloudflareImageFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkPhotoOutputSerializer(TestCase):
|
||||
"""Tests for ParkPhotoOutputSerializer."""
|
||||
|
||||
def test__serialize__valid_photo__returns_all_fields(self):
|
||||
"""Test serializing a park photo returns all expected fields."""
|
||||
user = UserFactory()
|
||||
park = ParkFactory()
|
||||
image = CloudflareImageFactory()
|
||||
photo = ParkPhotoFactory(
|
||||
park=park,
|
||||
uploaded_by=user,
|
||||
image=image,
|
||||
caption="Test caption",
|
||||
alt_text="Test alt text",
|
||||
is_primary=True,
|
||||
is_approved=True,
|
||||
)
|
||||
|
||||
serializer = ParkPhotoOutputSerializer(photo)
|
||||
data = serializer.data
|
||||
|
||||
assert "id" in data
|
||||
assert data["caption"] == "Test caption"
|
||||
assert data["alt_text"] == "Test alt text"
|
||||
assert data["is_primary"] is True
|
||||
assert data["is_approved"] is True
|
||||
assert data["uploaded_by_username"] == user.username
|
||||
assert data["park_slug"] == park.slug
|
||||
assert data["park_name"] == park.name
|
||||
|
||||
def test__serialize__photo_with_image__returns_image_url(self):
|
||||
"""Test serializing a photo with image returns URL."""
|
||||
photo = ParkPhotoFactory()
|
||||
|
||||
serializer = ParkPhotoOutputSerializer(photo)
|
||||
data = serializer.data
|
||||
|
||||
assert "image_url" in data
|
||||
assert "image_variants" in data
|
||||
|
||||
def test__serialize__photo_without_image__returns_none_for_image_fields(self):
|
||||
"""Test serializing photo without image returns None for image fields."""
|
||||
photo = ParkPhotoFactory()
|
||||
photo.image = None
|
||||
photo.save()
|
||||
|
||||
serializer = ParkPhotoOutputSerializer(photo)
|
||||
data = serializer.data
|
||||
|
||||
assert data["image_url"] is None
|
||||
assert data["image_variants"] == {}
|
||||
|
||||
def test__get_file_size__photo_with_image__returns_file_size(self):
|
||||
"""Test get_file_size method returns file size."""
|
||||
photo = ParkPhotoFactory()
|
||||
|
||||
serializer = ParkPhotoOutputSerializer(photo)
|
||||
|
||||
# file_size comes from the model property
|
||||
assert "file_size" in serializer.data
|
||||
|
||||
def test__get_dimensions__photo_with_image__returns_dimensions(self):
|
||||
"""Test get_dimensions method returns [width, height]."""
|
||||
photo = ParkPhotoFactory()
|
||||
|
||||
serializer = ParkPhotoOutputSerializer(photo)
|
||||
|
||||
assert "dimensions" in serializer.data
|
||||
|
||||
def test__get_image_variants__photo_with_image__returns_variant_urls(self):
|
||||
"""Test get_image_variants returns all variant URLs."""
|
||||
image = CloudflareImageFactory()
|
||||
photo = ParkPhotoFactory(image=image)
|
||||
|
||||
serializer = ParkPhotoOutputSerializer(photo)
|
||||
data = serializer.data
|
||||
|
||||
if photo.image:
|
||||
variants = data["image_variants"]
|
||||
assert "thumbnail" in variants
|
||||
assert "medium" in variants
|
||||
assert "large" in variants
|
||||
assert "public" in variants
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkPhotoCreateInputSerializer(TestCase):
|
||||
"""Tests for ParkPhotoCreateInputSerializer."""
|
||||
|
||||
def test__serialize__valid_data__returns_expected_fields(self):
|
||||
"""Test serializing valid create data."""
|
||||
image = CloudflareImageFactory()
|
||||
data = {
|
||||
"image": image.pk,
|
||||
"caption": "New photo caption",
|
||||
"alt_text": "Description of the image",
|
||||
"is_primary": False,
|
||||
}
|
||||
|
||||
serializer = ParkPhotoCreateInputSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert "caption" in serializer.validated_data
|
||||
assert "alt_text" in serializer.validated_data
|
||||
assert "is_primary" in serializer.validated_data
|
||||
|
||||
def test__validate__missing_required_fields__returns_error(self):
|
||||
"""Test validation fails with missing required fields."""
|
||||
data = {}
|
||||
|
||||
serializer = ParkPhotoCreateInputSerializer(data=data)
|
||||
|
||||
# image is required since it's not in read_only_fields
|
||||
assert not serializer.is_valid()
|
||||
|
||||
def test__meta__fields__includes_expected_fields(self):
|
||||
"""Test Meta.fields includes the expected input fields."""
|
||||
expected_fields = ["image", "caption", "alt_text", "is_primary"]
|
||||
|
||||
assert list(ParkPhotoCreateInputSerializer.Meta.fields) == expected_fields
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkPhotoUpdateInputSerializer(TestCase):
|
||||
"""Tests for ParkPhotoUpdateInputSerializer."""
|
||||
|
||||
def test__serialize__valid_data__returns_expected_fields(self):
|
||||
"""Test serializing valid update data."""
|
||||
data = {
|
||||
"caption": "Updated caption",
|
||||
"alt_text": "Updated alt text",
|
||||
"is_primary": True,
|
||||
}
|
||||
|
||||
serializer = ParkPhotoUpdateInputSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["caption"] == "Updated caption"
|
||||
assert serializer.validated_data["alt_text"] == "Updated alt text"
|
||||
assert serializer.validated_data["is_primary"] is True
|
||||
|
||||
def test__serialize__partial_update__validates_partial_data(self):
|
||||
"""Test partial update with only some fields."""
|
||||
photo = ParkPhotoFactory()
|
||||
data = {"caption": "Only caption updated"}
|
||||
|
||||
serializer = ParkPhotoUpdateInputSerializer(photo, data=data, partial=True)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["caption"] == "Only caption updated"
|
||||
|
||||
def test__meta__fields__excludes_image_field(self):
|
||||
"""Test Meta.fields excludes image field for updates."""
|
||||
expected_fields = ["caption", "alt_text", "is_primary"]
|
||||
|
||||
assert list(ParkPhotoUpdateInputSerializer.Meta.fields) == expected_fields
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkPhotoListOutputSerializer(TestCase):
|
||||
"""Tests for ParkPhotoListOutputSerializer."""
|
||||
|
||||
def test__serialize__photo__returns_list_fields_only(self):
|
||||
"""Test serializing returns only list-appropriate fields."""
|
||||
user = UserFactory()
|
||||
photo = ParkPhotoFactory(uploaded_by=user)
|
||||
|
||||
serializer = ParkPhotoListOutputSerializer(photo)
|
||||
data = serializer.data
|
||||
|
||||
assert "id" in data
|
||||
assert "image" in data
|
||||
assert "caption" in data
|
||||
assert "is_primary" in data
|
||||
assert "is_approved" in data
|
||||
assert "created_at" in data
|
||||
assert "uploaded_by_username" in data
|
||||
# Should NOT include detailed fields
|
||||
assert "image_variants" not in data
|
||||
assert "file_size" not in data
|
||||
assert "dimensions" not in data
|
||||
|
||||
def test__serialize__multiple_photos__returns_list(self):
|
||||
"""Test serializing multiple photos returns a list."""
|
||||
photos = [ParkPhotoFactory() for _ in range(3)]
|
||||
|
||||
serializer = ParkPhotoListOutputSerializer(photos, many=True)
|
||||
|
||||
assert len(serializer.data) == 3
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
class TestParkPhotoApprovalInputSerializer(TestCase):
|
||||
"""Tests for ParkPhotoApprovalInputSerializer."""
|
||||
|
||||
def test__validate__valid_photo_ids__returns_validated_data(self):
|
||||
"""Test validation with valid photo IDs."""
|
||||
data = {
|
||||
"photo_ids": [1, 2, 3],
|
||||
"approve": True,
|
||||
}
|
||||
|
||||
serializer = ParkPhotoApprovalInputSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["photo_ids"] == [1, 2, 3]
|
||||
assert serializer.validated_data["approve"] is True
|
||||
|
||||
def test__validate__approve_default__defaults_to_true(self):
|
||||
"""Test approve field defaults to True."""
|
||||
data = {"photo_ids": [1, 2]}
|
||||
|
||||
serializer = ParkPhotoApprovalInputSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["approve"] is True
|
||||
|
||||
def test__validate__empty_photo_ids__is_valid(self):
|
||||
"""Test empty photo_ids list is valid."""
|
||||
data = {"photo_ids": [], "approve": False}
|
||||
|
||||
serializer = ParkPhotoApprovalInputSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["photo_ids"] == []
|
||||
|
||||
def test__validate__missing_photo_ids__returns_error(self):
|
||||
"""Test validation fails without photo_ids."""
|
||||
data = {"approve": True}
|
||||
|
||||
serializer = ParkPhotoApprovalInputSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "photo_ids" in serializer.errors
|
||||
|
||||
def test__validate__invalid_photo_ids__returns_error(self):
|
||||
"""Test validation fails with non-integer photo IDs."""
|
||||
data = {"photo_ids": ["invalid", "ids"]}
|
||||
|
||||
serializer = ParkPhotoApprovalInputSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
|
||||
|
||||
class TestParkPhotoStatsOutputSerializer(TestCase):
|
||||
"""Tests for ParkPhotoStatsOutputSerializer."""
|
||||
|
||||
def test__serialize__stats_dict__returns_all_fields(self):
|
||||
"""Test serializing stats dictionary."""
|
||||
stats = {
|
||||
"total_photos": 100,
|
||||
"approved_photos": 80,
|
||||
"pending_photos": 20,
|
||||
"has_primary": True,
|
||||
"recent_uploads": 5,
|
||||
}
|
||||
|
||||
serializer = ParkPhotoStatsOutputSerializer(data=stats)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["total_photos"] == 100
|
||||
assert serializer.validated_data["approved_photos"] == 80
|
||||
assert serializer.validated_data["pending_photos"] == 20
|
||||
assert serializer.validated_data["has_primary"] is True
|
||||
assert serializer.validated_data["recent_uploads"] == 5
|
||||
|
||||
def test__validate__missing_fields__returns_error(self):
|
||||
"""Test validation fails with missing stats fields."""
|
||||
stats = {"total_photos": 100} # Missing other required fields
|
||||
|
||||
serializer = ParkPhotoStatsOutputSerializer(data=stats)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestHybridParkSerializer(TestCase):
|
||||
"""Tests for HybridParkSerializer."""
|
||||
|
||||
def test__serialize__park_with_all_fields__returns_complete_data(self):
|
||||
"""Test serializing park with all fields populated."""
|
||||
park = ParkFactory()
|
||||
|
||||
serializer = HybridParkSerializer(park)
|
||||
data = serializer.data
|
||||
|
||||
assert "id" in data
|
||||
assert "name" in data
|
||||
assert "slug" in data
|
||||
assert "status" in data
|
||||
assert "operator_name" in data
|
||||
|
||||
def test__serialize__park_without_location__returns_null_location_fields(self):
|
||||
"""Test serializing park without location returns null for location fields."""
|
||||
park = ParkFactory()
|
||||
# Remove location if it exists
|
||||
if hasattr(park, 'location') and park.location:
|
||||
park.location.delete()
|
||||
|
||||
serializer = HybridParkSerializer(park)
|
||||
data = serializer.data
|
||||
|
||||
# Location fields should be None when no location
|
||||
assert "city" in data
|
||||
assert "state" in data
|
||||
assert "country" in data
|
||||
|
||||
def test__get_city__park_with_location__returns_city(self):
|
||||
"""Test get_city returns city from location."""
|
||||
park = ParkFactory()
|
||||
|
||||
# Create a mock location
|
||||
mock_location = Mock()
|
||||
mock_location.city = "Orlando"
|
||||
mock_location.state = "FL"
|
||||
mock_location.country = "USA"
|
||||
mock_location.continent = "North America"
|
||||
mock_location.coordinates = [-81.3792, 28.5383] # [lon, lat]
|
||||
|
||||
park.location = mock_location
|
||||
|
||||
serializer = HybridParkSerializer(park)
|
||||
|
||||
assert serializer.get_city(park) == "Orlando"
|
||||
|
||||
def test__get_latitude__park_with_coordinates__returns_latitude(self):
|
||||
"""Test get_latitude returns correct value from coordinates."""
|
||||
park = ParkFactory()
|
||||
|
||||
mock_location = Mock()
|
||||
mock_location.coordinates = [-81.3792, 28.5383] # [lon, lat]
|
||||
park.location = mock_location
|
||||
|
||||
serializer = HybridParkSerializer(park)
|
||||
|
||||
# Latitude is index 1 in PostGIS [lon, lat] format
|
||||
assert serializer.get_latitude(park) == 28.5383
|
||||
|
||||
def test__get_longitude__park_with_coordinates__returns_longitude(self):
|
||||
"""Test get_longitude returns correct value from coordinates."""
|
||||
park = ParkFactory()
|
||||
|
||||
mock_location = Mock()
|
||||
mock_location.coordinates = [-81.3792, 28.5383] # [lon, lat]
|
||||
park.location = mock_location
|
||||
|
||||
serializer = HybridParkSerializer(park)
|
||||
|
||||
# Longitude is index 0 in PostGIS [lon, lat] format
|
||||
assert serializer.get_longitude(park) == -81.3792
|
||||
|
||||
def test__get_banner_image_url__park_with_banner__returns_url(self):
|
||||
"""Test get_banner_image_url returns URL when banner exists."""
|
||||
park = ParkFactory()
|
||||
|
||||
mock_image = Mock()
|
||||
mock_image.url = "https://example.com/banner.jpg"
|
||||
|
||||
mock_banner = Mock()
|
||||
mock_banner.image = mock_image
|
||||
|
||||
park.banner_image = mock_banner
|
||||
|
||||
serializer = HybridParkSerializer(park)
|
||||
|
||||
assert serializer.get_banner_image_url(park) == "https://example.com/banner.jpg"
|
||||
|
||||
def test__get_banner_image_url__park_without_banner__returns_none(self):
|
||||
"""Test get_banner_image_url returns None when no banner."""
|
||||
park = ParkFactory()
|
||||
park.banner_image = None
|
||||
|
||||
serializer = HybridParkSerializer(park)
|
||||
|
||||
assert serializer.get_banner_image_url(park) is None
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkSerializer(TestCase):
|
||||
"""Tests for ParkSerializer (legacy)."""
|
||||
|
||||
def test__serialize__park__returns_basic_fields(self):
|
||||
"""Test serializing park returns basic fields."""
|
||||
park = ParkFactory()
|
||||
|
||||
serializer = ParkSerializer(park)
|
||||
data = serializer.data
|
||||
|
||||
assert "id" in data
|
||||
assert "name" in data
|
||||
assert "slug" in data
|
||||
assert "status" in data
|
||||
assert "website" in data
|
||||
|
||||
def test__serialize__multiple_parks__returns_list(self):
|
||||
"""Test serializing multiple parks returns a list."""
|
||||
parks = [ParkFactory() for _ in range(3)]
|
||||
|
||||
serializer = ParkSerializer(parks, many=True)
|
||||
|
||||
assert len(serializer.data) == 3
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkPhotoSerializer(TestCase):
|
||||
"""Tests for legacy ParkPhotoSerializer."""
|
||||
|
||||
def test__serialize__photo__returns_legacy_fields(self):
|
||||
"""Test serializing photo returns legacy field set."""
|
||||
photo = ParkPhotoFactory()
|
||||
|
||||
serializer = ParkPhotoSerializer(photo)
|
||||
data = serializer.data
|
||||
|
||||
assert "id" in data
|
||||
assert "image" in data
|
||||
assert "caption" in data
|
||||
assert "alt_text" in data
|
||||
assert "is_primary" in data
|
||||
|
||||
def test__meta__fields__matches_legacy_format(self):
|
||||
"""Test Meta.fields matches legacy format."""
|
||||
expected_fields = (
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
"uploaded_at",
|
||||
"uploaded_by",
|
||||
)
|
||||
|
||||
assert ParkPhotoSerializer.Meta.fields == expected_fields
|
||||
573
backend/tests/serializers/test_ride_serializers.py
Normal file
573
backend/tests/serializers/test_ride_serializers.py
Normal file
@@ -0,0 +1,573 @@
|
||||
"""
|
||||
Tests for Ride serializers.
|
||||
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.api.v1.rides.serializers import (
|
||||
RidePhotoOutputSerializer,
|
||||
RidePhotoCreateInputSerializer,
|
||||
RidePhotoUpdateInputSerializer,
|
||||
RidePhotoListOutputSerializer,
|
||||
RidePhotoApprovalInputSerializer,
|
||||
RidePhotoStatsOutputSerializer,
|
||||
RidePhotoTypeFilterSerializer,
|
||||
RidePhotoSerializer,
|
||||
HybridRideSerializer,
|
||||
RideSerializer,
|
||||
)
|
||||
|
||||
from tests.factories import (
|
||||
RideFactory,
|
||||
RidePhotoFactory,
|
||||
ParkFactory,
|
||||
UserFactory,
|
||||
CloudflareImageFactory,
|
||||
ManufacturerCompanyFactory,
|
||||
DesignerCompanyFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRidePhotoOutputSerializer(TestCase):
|
||||
"""Tests for RidePhotoOutputSerializer."""
|
||||
|
||||
def test__serialize__valid_photo__returns_all_fields(self):
|
||||
"""Test serializing a ride photo returns all expected fields."""
|
||||
user = UserFactory()
|
||||
ride = RideFactory()
|
||||
image = CloudflareImageFactory()
|
||||
photo = RidePhotoFactory(
|
||||
ride=ride,
|
||||
uploaded_by=user,
|
||||
image=image,
|
||||
caption="Test caption",
|
||||
alt_text="Test alt text",
|
||||
is_primary=True,
|
||||
is_approved=True,
|
||||
)
|
||||
|
||||
serializer = RidePhotoOutputSerializer(photo)
|
||||
data = serializer.data
|
||||
|
||||
assert "id" in data
|
||||
assert data["caption"] == "Test caption"
|
||||
assert data["alt_text"] == "Test alt text"
|
||||
assert data["is_primary"] is True
|
||||
assert data["is_approved"] is True
|
||||
assert data["uploaded_by_username"] == user.username
|
||||
assert data["ride_slug"] == ride.slug
|
||||
assert data["ride_name"] == ride.name
|
||||
assert data["park_slug"] == ride.park.slug
|
||||
assert data["park_name"] == ride.park.name
|
||||
|
||||
def test__serialize__photo_with_image__returns_image_url(self):
|
||||
"""Test serializing a photo with image returns URL."""
|
||||
photo = RidePhotoFactory()
|
||||
|
||||
serializer = RidePhotoOutputSerializer(photo)
|
||||
data = serializer.data
|
||||
|
||||
assert "image_url" in data
|
||||
assert "image_variants" in data
|
||||
|
||||
def test__serialize__photo_without_image__returns_none_for_image_fields(self):
|
||||
"""Test serializing photo without image returns None for image fields."""
|
||||
photo = RidePhotoFactory()
|
||||
photo.image = None
|
||||
photo.save()
|
||||
|
||||
serializer = RidePhotoOutputSerializer(photo)
|
||||
data = serializer.data
|
||||
|
||||
assert data["image_url"] is None
|
||||
assert data["image_variants"] == {}
|
||||
|
||||
def test__get_image_variants__photo_with_image__returns_variant_urls(self):
|
||||
"""Test get_image_variants returns all variant URLs."""
|
||||
image = CloudflareImageFactory()
|
||||
photo = RidePhotoFactory(image=image)
|
||||
|
||||
serializer = RidePhotoOutputSerializer(photo)
|
||||
data = serializer.data
|
||||
|
||||
if photo.image:
|
||||
variants = data["image_variants"]
|
||||
assert "thumbnail" in variants
|
||||
assert "medium" in variants
|
||||
assert "large" in variants
|
||||
assert "public" in variants
|
||||
|
||||
def test__serialize__includes_photo_type(self):
|
||||
"""Test serializing includes photo_type field."""
|
||||
photo = RidePhotoFactory()
|
||||
|
||||
serializer = RidePhotoOutputSerializer(photo)
|
||||
data = serializer.data
|
||||
|
||||
assert "photo_type" in data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRidePhotoCreateInputSerializer(TestCase):
|
||||
"""Tests for RidePhotoCreateInputSerializer."""
|
||||
|
||||
def test__serialize__valid_data__returns_expected_fields(self):
|
||||
"""Test serializing valid create data."""
|
||||
image = CloudflareImageFactory()
|
||||
data = {
|
||||
"image": image.pk,
|
||||
"caption": "New photo caption",
|
||||
"alt_text": "Description of the image",
|
||||
"photo_type": "exterior",
|
||||
"is_primary": False,
|
||||
}
|
||||
|
||||
serializer = RidePhotoCreateInputSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert "caption" in serializer.validated_data
|
||||
assert "alt_text" in serializer.validated_data
|
||||
assert "photo_type" in serializer.validated_data
|
||||
assert "is_primary" in serializer.validated_data
|
||||
|
||||
def test__validate__missing_required_fields__returns_error(self):
|
||||
"""Test validation fails with missing required fields."""
|
||||
data = {}
|
||||
|
||||
serializer = RidePhotoCreateInputSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
|
||||
def test__meta__fields__includes_photo_type(self):
|
||||
"""Test Meta.fields includes photo_type for ride photos."""
|
||||
expected_fields = ["image", "caption", "alt_text", "photo_type", "is_primary"]
|
||||
|
||||
assert list(RidePhotoCreateInputSerializer.Meta.fields) == expected_fields
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRidePhotoUpdateInputSerializer(TestCase):
|
||||
"""Tests for RidePhotoUpdateInputSerializer."""
|
||||
|
||||
def test__serialize__valid_data__returns_expected_fields(self):
|
||||
"""Test serializing valid update data."""
|
||||
data = {
|
||||
"caption": "Updated caption",
|
||||
"alt_text": "Updated alt text",
|
||||
"photo_type": "queue",
|
||||
"is_primary": True,
|
||||
}
|
||||
|
||||
serializer = RidePhotoUpdateInputSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["caption"] == "Updated caption"
|
||||
assert serializer.validated_data["photo_type"] == "queue"
|
||||
|
||||
def test__serialize__partial_update__validates_partial_data(self):
|
||||
"""Test partial update with only some fields."""
|
||||
photo = RidePhotoFactory()
|
||||
data = {"caption": "Only caption updated"}
|
||||
|
||||
serializer = RidePhotoUpdateInputSerializer(photo, data=data, partial=True)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
def test__meta__fields__includes_photo_type(self):
|
||||
"""Test Meta.fields includes photo_type for updates."""
|
||||
expected_fields = ["caption", "alt_text", "photo_type", "is_primary"]
|
||||
|
||||
assert list(RidePhotoUpdateInputSerializer.Meta.fields) == expected_fields
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRidePhotoListOutputSerializer(TestCase):
|
||||
"""Tests for RidePhotoListOutputSerializer."""
|
||||
|
||||
def test__serialize__photo__returns_list_fields_only(self):
|
||||
"""Test serializing returns only list-appropriate fields."""
|
||||
user = UserFactory()
|
||||
photo = RidePhotoFactory(uploaded_by=user)
|
||||
|
||||
serializer = RidePhotoListOutputSerializer(photo)
|
||||
data = serializer.data
|
||||
|
||||
assert "id" in data
|
||||
assert "image" in data
|
||||
assert "caption" in data
|
||||
assert "photo_type" in data
|
||||
assert "is_primary" in data
|
||||
assert "is_approved" in data
|
||||
assert "created_at" in data
|
||||
assert "uploaded_by_username" in data
|
||||
# Should NOT include detailed fields
|
||||
assert "image_variants" not in data
|
||||
assert "file_size" not in data
|
||||
assert "dimensions" not in data
|
||||
|
||||
def test__serialize__multiple_photos__returns_list(self):
|
||||
"""Test serializing multiple photos returns a list."""
|
||||
photos = [RidePhotoFactory() for _ in range(3)]
|
||||
|
||||
serializer = RidePhotoListOutputSerializer(photos, many=True)
|
||||
|
||||
assert len(serializer.data) == 3
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
class TestRidePhotoApprovalInputSerializer(TestCase):
|
||||
"""Tests for RidePhotoApprovalInputSerializer."""
|
||||
|
||||
def test__validate__valid_photo_ids__returns_validated_data(self):
|
||||
"""Test validation with valid photo IDs."""
|
||||
data = {
|
||||
"photo_ids": [1, 2, 3],
|
||||
"approve": True,
|
||||
}
|
||||
|
||||
serializer = RidePhotoApprovalInputSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["photo_ids"] == [1, 2, 3]
|
||||
assert serializer.validated_data["approve"] is True
|
||||
|
||||
def test__validate__approve_default__defaults_to_true(self):
|
||||
"""Test approve field defaults to True."""
|
||||
data = {"photo_ids": [1, 2]}
|
||||
|
||||
serializer = RidePhotoApprovalInputSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["approve"] is True
|
||||
|
||||
def test__validate__empty_photo_ids__is_valid(self):
|
||||
"""Test empty photo_ids list is valid."""
|
||||
data = {"photo_ids": [], "approve": False}
|
||||
|
||||
serializer = RidePhotoApprovalInputSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
def test__validate__missing_photo_ids__returns_error(self):
|
||||
"""Test validation fails without photo_ids."""
|
||||
data = {"approve": True}
|
||||
|
||||
serializer = RidePhotoApprovalInputSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "photo_ids" in serializer.errors
|
||||
|
||||
|
||||
class TestRidePhotoStatsOutputSerializer(TestCase):
|
||||
"""Tests for RidePhotoStatsOutputSerializer."""
|
||||
|
||||
def test__serialize__stats_dict__returns_all_fields(self):
|
||||
"""Test serializing stats dictionary."""
|
||||
stats = {
|
||||
"total_photos": 50,
|
||||
"approved_photos": 40,
|
||||
"pending_photos": 10,
|
||||
"has_primary": True,
|
||||
"recent_uploads": 3,
|
||||
"by_type": {"exterior": 20, "queue": 10, "onride": 10, "other": 10},
|
||||
}
|
||||
|
||||
serializer = RidePhotoStatsOutputSerializer(data=stats)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["total_photos"] == 50
|
||||
assert serializer.validated_data["by_type"]["exterior"] == 20
|
||||
|
||||
def test__validate__includes_by_type_field(self):
|
||||
"""Test stats include by_type breakdown."""
|
||||
stats = {
|
||||
"total_photos": 10,
|
||||
"approved_photos": 8,
|
||||
"pending_photos": 2,
|
||||
"has_primary": False,
|
||||
"recent_uploads": 1,
|
||||
"by_type": {"exterior": 10},
|
||||
}
|
||||
|
||||
serializer = RidePhotoStatsOutputSerializer(data=stats)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert "by_type" in serializer.validated_data
|
||||
|
||||
|
||||
class TestRidePhotoTypeFilterSerializer(TestCase):
|
||||
"""Tests for RidePhotoTypeFilterSerializer."""
|
||||
|
||||
def test__validate__valid_photo_type__returns_validated_data(self):
|
||||
"""Test validation with valid photo type."""
|
||||
data = {"photo_type": "exterior"}
|
||||
|
||||
serializer = RidePhotoTypeFilterSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert serializer.validated_data["photo_type"] == "exterior"
|
||||
|
||||
def test__validate__all_photo_types__are_valid(self):
|
||||
"""Test all defined photo types are valid."""
|
||||
valid_types = ["exterior", "queue", "station", "onride", "construction", "other"]
|
||||
|
||||
for photo_type in valid_types:
|
||||
serializer = RidePhotoTypeFilterSerializer(data={"photo_type": photo_type})
|
||||
assert serializer.is_valid(), f"Photo type {photo_type} should be valid"
|
||||
|
||||
def test__validate__invalid_photo_type__returns_error(self):
|
||||
"""Test invalid photo type returns error."""
|
||||
data = {"photo_type": "invalid_type"}
|
||||
|
||||
serializer = RidePhotoTypeFilterSerializer(data=data)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "photo_type" in serializer.errors
|
||||
|
||||
def test__validate__empty_photo_type__is_valid(self):
|
||||
"""Test empty/missing photo_type is valid (optional field)."""
|
||||
data = {}
|
||||
|
||||
serializer = RidePhotoTypeFilterSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestHybridRideSerializer(TestCase):
|
||||
"""Tests for HybridRideSerializer."""
|
||||
|
||||
def test__serialize__ride_with_all_fields__returns_complete_data(self):
|
||||
"""Test serializing ride with all fields populated."""
|
||||
ride = RideFactory()
|
||||
|
||||
serializer = HybridRideSerializer(ride)
|
||||
data = serializer.data
|
||||
|
||||
assert "id" in data
|
||||
assert "name" in data
|
||||
assert "slug" in data
|
||||
assert "category" in data
|
||||
assert "status" in data
|
||||
assert "park_name" in data
|
||||
assert "park_slug" in data
|
||||
assert "manufacturer_name" in data
|
||||
|
||||
def test__serialize__ride_with_manufacturer__returns_manufacturer_fields(self):
|
||||
"""Test serializing includes manufacturer information."""
|
||||
manufacturer = ManufacturerCompanyFactory(name="Test Manufacturer")
|
||||
ride = RideFactory(manufacturer=manufacturer)
|
||||
|
||||
serializer = HybridRideSerializer(ride)
|
||||
data = serializer.data
|
||||
|
||||
assert data["manufacturer_name"] == "Test Manufacturer"
|
||||
assert "manufacturer_slug" in data
|
||||
|
||||
def test__serialize__ride_with_designer__returns_designer_fields(self):
|
||||
"""Test serializing includes designer information."""
|
||||
designer = DesignerCompanyFactory(name="Test Designer")
|
||||
ride = RideFactory(designer=designer)
|
||||
|
||||
serializer = HybridRideSerializer(ride)
|
||||
data = serializer.data
|
||||
|
||||
assert data["designer_name"] == "Test Designer"
|
||||
assert "designer_slug" in data
|
||||
|
||||
def test__get_park_city__ride_with_park_location__returns_city(self):
|
||||
"""Test get_park_city returns city from park location."""
|
||||
ride = RideFactory()
|
||||
|
||||
mock_location = Mock()
|
||||
mock_location.city = "Orlando"
|
||||
mock_location.state = "FL"
|
||||
mock_location.country = "USA"
|
||||
|
||||
ride.park.location = mock_location
|
||||
|
||||
serializer = HybridRideSerializer(ride)
|
||||
|
||||
assert serializer.get_park_city(ride) == "Orlando"
|
||||
|
||||
def test__get_park_city__ride_without_park_location__returns_none(self):
|
||||
"""Test get_park_city returns None when no location."""
|
||||
ride = RideFactory()
|
||||
ride.park.location = None
|
||||
|
||||
serializer = HybridRideSerializer(ride)
|
||||
|
||||
assert serializer.get_park_city(ride) is None
|
||||
|
||||
def test__get_coaster_height_ft__ride_with_stats__returns_height(self):
|
||||
"""Test get_coaster_height_ft returns height from coaster stats."""
|
||||
ride = RideFactory()
|
||||
|
||||
mock_stats = Mock()
|
||||
mock_stats.height_ft = 205.5
|
||||
mock_stats.length_ft = 5000
|
||||
mock_stats.speed_mph = 70
|
||||
mock_stats.inversions = 4
|
||||
|
||||
ride.coaster_stats = mock_stats
|
||||
|
||||
serializer = HybridRideSerializer(ride)
|
||||
|
||||
assert serializer.get_coaster_height_ft(ride) == 205.5
|
||||
|
||||
def test__get_coaster_inversions__ride_with_stats__returns_inversions(self):
|
||||
"""Test get_coaster_inversions returns inversions count."""
|
||||
ride = RideFactory()
|
||||
|
||||
mock_stats = Mock()
|
||||
mock_stats.inversions = 7
|
||||
|
||||
ride.coaster_stats = mock_stats
|
||||
|
||||
serializer = HybridRideSerializer(ride)
|
||||
|
||||
assert serializer.get_coaster_inversions(ride) == 7
|
||||
|
||||
def test__get_coaster_height_ft__ride_without_stats__returns_none(self):
|
||||
"""Test coaster stat methods return None when no stats."""
|
||||
ride = RideFactory()
|
||||
ride.coaster_stats = None
|
||||
|
||||
serializer = HybridRideSerializer(ride)
|
||||
|
||||
assert serializer.get_coaster_height_ft(ride) is None
|
||||
assert serializer.get_coaster_length_ft(ride) is None
|
||||
assert serializer.get_coaster_speed_mph(ride) is None
|
||||
assert serializer.get_coaster_inversions(ride) is None
|
||||
|
||||
def test__get_banner_image_url__ride_with_banner__returns_url(self):
|
||||
"""Test get_banner_image_url returns URL when banner exists."""
|
||||
ride = RideFactory()
|
||||
|
||||
mock_image = Mock()
|
||||
mock_image.url = "https://example.com/ride-banner.jpg"
|
||||
|
||||
mock_banner = Mock()
|
||||
mock_banner.image = mock_image
|
||||
|
||||
ride.banner_image = mock_banner
|
||||
|
||||
serializer = HybridRideSerializer(ride)
|
||||
|
||||
assert serializer.get_banner_image_url(ride) == "https://example.com/ride-banner.jpg"
|
||||
|
||||
def test__get_banner_image_url__ride_without_banner__returns_none(self):
|
||||
"""Test get_banner_image_url returns None when no banner."""
|
||||
ride = RideFactory()
|
||||
ride.banner_image = None
|
||||
|
||||
serializer = HybridRideSerializer(ride)
|
||||
|
||||
assert serializer.get_banner_image_url(ride) is None
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def test__serialize__includes_ride_model_fields(self):
|
||||
"""Test serializing includes ride model information."""
|
||||
ride = RideFactory()
|
||||
|
||||
serializer = HybridRideSerializer(ride)
|
||||
data = serializer.data
|
||||
|
||||
assert "ride_model_name" in data
|
||||
assert "ride_model_slug" in data
|
||||
assert "ride_model_category" in data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideSerializer(TestCase):
|
||||
"""Tests for RideSerializer (legacy)."""
|
||||
|
||||
def test__serialize__ride__returns_basic_fields(self):
|
||||
"""Test serializing ride returns basic fields."""
|
||||
ride = RideFactory()
|
||||
|
||||
serializer = RideSerializer(ride)
|
||||
data = serializer.data
|
||||
|
||||
assert "id" in data
|
||||
assert "name" in data
|
||||
assert "slug" in data
|
||||
assert "category" in data
|
||||
assert "status" in data
|
||||
assert "opening_date" in data
|
||||
|
||||
def test__serialize__multiple_rides__returns_list(self):
|
||||
"""Test serializing multiple rides returns a list."""
|
||||
rides = [RideFactory() for _ in range(3)]
|
||||
|
||||
serializer = RideSerializer(rides, many=True)
|
||||
|
||||
assert len(serializer.data) == 3
|
||||
|
||||
def test__meta__fields__matches_expected(self):
|
||||
"""Test Meta.fields matches expected field list."""
|
||||
expected_fields = [
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"park",
|
||||
"manufacturer",
|
||||
"designer",
|
||||
"category",
|
||||
"status",
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
]
|
||||
|
||||
assert list(RideSerializer.Meta.fields) == expected_fields
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRidePhotoSerializer(TestCase):
|
||||
"""Tests for legacy RidePhotoSerializer."""
|
||||
|
||||
def test__serialize__photo__returns_legacy_fields(self):
|
||||
"""Test serializing photo returns legacy field set."""
|
||||
photo = RidePhotoFactory()
|
||||
|
||||
serializer = RidePhotoSerializer(photo)
|
||||
data = serializer.data
|
||||
|
||||
assert "id" in data
|
||||
assert "image" in data
|
||||
assert "caption" in data
|
||||
assert "alt_text" in data
|
||||
assert "is_primary" in data
|
||||
assert "photo_type" in data
|
||||
|
||||
def test__meta__fields__matches_legacy_format(self):
|
||||
"""Test Meta.fields matches legacy format."""
|
||||
expected_fields = [
|
||||
"id",
|
||||
"image",
|
||||
"caption",
|
||||
"alt_text",
|
||||
"is_primary",
|
||||
"photo_type",
|
||||
"uploaded_at",
|
||||
"uploaded_by",
|
||||
]
|
||||
|
||||
assert list(RidePhotoSerializer.Meta.fields) == expected_fields
|
||||
6
backend/tests/services/__init__.py
Normal file
6
backend/tests/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Service layer tests.
|
||||
|
||||
This module contains tests for service classes that encapsulate
|
||||
business logic following Django styleguide patterns.
|
||||
"""
|
||||
290
backend/tests/services/test_park_media_service.py
Normal file
290
backend/tests/services/test_park_media_service.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Tests for ParkMediaService.
|
||||
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
from apps.parks.services.media_service import ParkMediaService
|
||||
from apps.parks.models import ParkPhoto
|
||||
|
||||
from tests.factories import (
|
||||
ParkFactory,
|
||||
ParkPhotoFactory,
|
||||
UserFactory,
|
||||
StaffUserFactory,
|
||||
CloudflareImageFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkMediaServiceUploadPhoto(TestCase):
|
||||
"""Tests for ParkMediaService.upload_photo."""
|
||||
|
||||
@patch("apps.parks.services.media_service.MediaService.validate_image_file")
|
||||
@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__valid_image__creates_photo(
|
||||
self,
|
||||
mock_exif,
|
||||
mock_caption,
|
||||
mock_process,
|
||||
mock_validate,
|
||||
):
|
||||
"""Test upload_photo creates photo with valid image."""
|
||||
mock_validate.return_value = (True, None)
|
||||
mock_process.return_value = Mock()
|
||||
mock_caption.return_value = "Photo by testuser"
|
||||
mock_exif.return_value = None
|
||||
|
||||
park = ParkFactory()
|
||||
user = UserFactory()
|
||||
image_file = SimpleUploadedFile(
|
||||
"test.jpg", b"fake image content", content_type="image/jpeg"
|
||||
)
|
||||
|
||||
photo = ParkMediaService.upload_photo(
|
||||
park=park,
|
||||
image_file=image_file,
|
||||
user=user,
|
||||
caption="Test caption",
|
||||
alt_text="Test alt",
|
||||
is_primary=False,
|
||||
auto_approve=True,
|
||||
)
|
||||
|
||||
assert photo.park == park
|
||||
assert photo.caption == "Test caption"
|
||||
assert photo.alt_text == "Test alt"
|
||||
assert photo.uploaded_by == user
|
||||
assert photo.is_approved is True
|
||||
|
||||
@patch("apps.parks.services.media_service.MediaService.validate_image_file")
|
||||
def test__upload_photo__invalid_image__raises_value_error(self, mock_validate):
|
||||
"""Test upload_photo raises ValueError for invalid image."""
|
||||
mock_validate.return_value = (False, "Invalid file type")
|
||||
|
||||
park = ParkFactory()
|
||||
user = UserFactory()
|
||||
image_file = SimpleUploadedFile(
|
||||
"test.txt", b"not an image", content_type="text/plain"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
ParkMediaService.upload_photo(
|
||||
park=park,
|
||||
image_file=image_file,
|
||||
user=user,
|
||||
)
|
||||
|
||||
assert "Invalid file type" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkMediaServiceGetParkPhotos(TestCase):
|
||||
"""Tests for ParkMediaService.get_park_photos."""
|
||||
|
||||
def test__get_park_photos__approved_only_true__filters_approved(self):
|
||||
"""Test get_park_photos with approved_only filters unapproved photos."""
|
||||
park = ParkFactory()
|
||||
approved = ParkPhotoFactory(park=park, is_approved=True)
|
||||
unapproved = ParkPhotoFactory(park=park, is_approved=False)
|
||||
|
||||
result = ParkMediaService.get_park_photos(park, approved_only=True)
|
||||
|
||||
assert approved in result
|
||||
assert unapproved not in result
|
||||
|
||||
def test__get_park_photos__approved_only_false__returns_all(self):
|
||||
"""Test get_park_photos with approved_only=False returns all photos."""
|
||||
park = ParkFactory()
|
||||
approved = ParkPhotoFactory(park=park, is_approved=True)
|
||||
unapproved = ParkPhotoFactory(park=park, is_approved=False)
|
||||
|
||||
result = ParkMediaService.get_park_photos(park, approved_only=False)
|
||||
|
||||
assert approved in result
|
||||
assert unapproved in result
|
||||
|
||||
def test__get_park_photos__primary_first__orders_primary_first(self):
|
||||
"""Test get_park_photos with primary_first orders primary photos first."""
|
||||
park = ParkFactory()
|
||||
non_primary = ParkPhotoFactory(park=park, is_primary=False, is_approved=True)
|
||||
primary = ParkPhotoFactory(park=park, is_primary=True, is_approved=True)
|
||||
|
||||
result = ParkMediaService.get_park_photos(park, primary_first=True)
|
||||
|
||||
# Primary should be first
|
||||
assert result[0] == primary
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkMediaServiceGetPrimaryPhoto(TestCase):
|
||||
"""Tests for ParkMediaService.get_primary_photo."""
|
||||
|
||||
def test__get_primary_photo__has_primary__returns_primary(self):
|
||||
"""Test get_primary_photo returns primary photo when exists."""
|
||||
park = ParkFactory()
|
||||
primary = ParkPhotoFactory(park=park, is_primary=True, is_approved=True)
|
||||
ParkPhotoFactory(park=park, is_primary=False, is_approved=True)
|
||||
|
||||
result = ParkMediaService.get_primary_photo(park)
|
||||
|
||||
assert result == primary
|
||||
|
||||
def test__get_primary_photo__no_primary__returns_none(self):
|
||||
"""Test get_primary_photo returns None when no primary exists."""
|
||||
park = ParkFactory()
|
||||
ParkPhotoFactory(park=park, is_primary=False, is_approved=True)
|
||||
|
||||
result = ParkMediaService.get_primary_photo(park)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test__get_primary_photo__unapproved_primary__returns_none(self):
|
||||
"""Test get_primary_photo ignores unapproved primary photos."""
|
||||
park = ParkFactory()
|
||||
ParkPhotoFactory(park=park, is_primary=True, is_approved=False)
|
||||
|
||||
result = ParkMediaService.get_primary_photo(park)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkMediaServiceSetPrimaryPhoto(TestCase):
|
||||
"""Tests for ParkMediaService.set_primary_photo."""
|
||||
|
||||
def test__set_primary_photo__valid_photo__sets_as_primary(self):
|
||||
"""Test set_primary_photo sets photo as primary."""
|
||||
park = ParkFactory()
|
||||
photo = ParkPhotoFactory(park=park, is_primary=False)
|
||||
|
||||
result = ParkMediaService.set_primary_photo(park, photo)
|
||||
|
||||
photo.refresh_from_db()
|
||||
assert result is True
|
||||
assert photo.is_primary is True
|
||||
|
||||
def test__set_primary_photo__unsets_existing_primary(self):
|
||||
"""Test set_primary_photo unsets existing primary photo."""
|
||||
park = ParkFactory()
|
||||
old_primary = ParkPhotoFactory(park=park, is_primary=True)
|
||||
new_primary = ParkPhotoFactory(park=park, is_primary=False)
|
||||
|
||||
ParkMediaService.set_primary_photo(park, new_primary)
|
||||
|
||||
old_primary.refresh_from_db()
|
||||
new_primary.refresh_from_db()
|
||||
|
||||
assert old_primary.is_primary is False
|
||||
assert new_primary.is_primary is True
|
||||
|
||||
def test__set_primary_photo__wrong_park__returns_false(self):
|
||||
"""Test set_primary_photo returns False for photo from different park."""
|
||||
park1 = ParkFactory()
|
||||
park2 = ParkFactory()
|
||||
photo = ParkPhotoFactory(park=park2)
|
||||
|
||||
result = ParkMediaService.set_primary_photo(park1, photo)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkMediaServiceApprovePhoto(TestCase):
|
||||
"""Tests for ParkMediaService.approve_photo."""
|
||||
|
||||
def test__approve_photo__unapproved_photo__approves_it(self):
|
||||
"""Test approve_photo approves an unapproved photo."""
|
||||
photo = ParkPhotoFactory(is_approved=False)
|
||||
staff_user = StaffUserFactory()
|
||||
|
||||
result = ParkMediaService.approve_photo(photo, staff_user)
|
||||
|
||||
photo.refresh_from_db()
|
||||
assert result is True
|
||||
assert photo.is_approved is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkMediaServiceDeletePhoto(TestCase):
|
||||
"""Tests for ParkMediaService.delete_photo."""
|
||||
|
||||
def test__delete_photo__valid_photo__deletes_it(self):
|
||||
"""Test delete_photo deletes a photo."""
|
||||
photo = ParkPhotoFactory()
|
||||
photo_id = photo.pk
|
||||
staff_user = StaffUserFactory()
|
||||
|
||||
result = ParkMediaService.delete_photo(photo, staff_user)
|
||||
|
||||
assert result is True
|
||||
assert not ParkPhoto.objects.filter(pk=photo_id).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkMediaServiceGetPhotoStats(TestCase):
|
||||
"""Tests for ParkMediaService.get_photo_stats."""
|
||||
|
||||
def test__get_photo_stats__returns_correct_counts(self):
|
||||
"""Test get_photo_stats returns correct statistics."""
|
||||
park = ParkFactory()
|
||||
ParkPhotoFactory(park=park, is_approved=True)
|
||||
ParkPhotoFactory(park=park, is_approved=True)
|
||||
ParkPhotoFactory(park=park, is_approved=False)
|
||||
ParkPhotoFactory(park=park, is_approved=True, is_primary=True)
|
||||
|
||||
stats = ParkMediaService.get_photo_stats(park)
|
||||
|
||||
assert stats["total_photos"] == 4
|
||||
assert stats["approved_photos"] == 3
|
||||
assert stats["pending_photos"] == 1
|
||||
assert stats["has_primary"] is True
|
||||
|
||||
def test__get_photo_stats__no_photos__returns_zeros(self):
|
||||
"""Test get_photo_stats returns zeros when no photos."""
|
||||
park = ParkFactory()
|
||||
|
||||
stats = ParkMediaService.get_photo_stats(park)
|
||||
|
||||
assert stats["total_photos"] == 0
|
||||
assert stats["approved_photos"] == 0
|
||||
assert stats["pending_photos"] == 0
|
||||
assert stats["has_primary"] is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkMediaServiceBulkApprovePhotos(TestCase):
|
||||
"""Tests for ParkMediaService.bulk_approve_photos."""
|
||||
|
||||
def test__bulk_approve_photos__multiple_photos__approves_all(self):
|
||||
"""Test bulk_approve_photos approves multiple photos."""
|
||||
park = ParkFactory()
|
||||
photo1 = ParkPhotoFactory(park=park, is_approved=False)
|
||||
photo2 = ParkPhotoFactory(park=park, is_approved=False)
|
||||
photo3 = ParkPhotoFactory(park=park, is_approved=False)
|
||||
staff_user = StaffUserFactory()
|
||||
|
||||
count = ParkMediaService.bulk_approve_photos([photo1, photo2, photo3], staff_user)
|
||||
|
||||
assert count == 3
|
||||
photo1.refresh_from_db()
|
||||
photo2.refresh_from_db()
|
||||
photo3.refresh_from_db()
|
||||
assert photo1.is_approved is True
|
||||
assert photo2.is_approved is True
|
||||
assert photo3.is_approved is True
|
||||
|
||||
def test__bulk_approve_photos__empty_list__returns_zero(self):
|
||||
"""Test bulk_approve_photos with empty list returns 0."""
|
||||
staff_user = StaffUserFactory()
|
||||
|
||||
count = ParkMediaService.bulk_approve_photos([], staff_user)
|
||||
|
||||
assert count == 0
|
||||
381
backend/tests/services/test_ride_service.py
Normal file
381
backend/tests/services/test_ride_service.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Tests for RideService.
|
||||
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.rides.services import RideService
|
||||
from apps.rides.models import Ride
|
||||
|
||||
from tests.factories import (
|
||||
ParkFactory,
|
||||
RideFactory,
|
||||
RideModelFactory,
|
||||
ParkAreaFactory,
|
||||
UserFactory,
|
||||
ManufacturerCompanyFactory,
|
||||
DesignerCompanyFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideServiceCreateRide(TestCase):
|
||||
"""Tests for RideService.create_ride."""
|
||||
|
||||
def test__create_ride__valid_data__creates_ride(self):
|
||||
"""Test create_ride creates ride with valid data."""
|
||||
park = ParkFactory()
|
||||
user = UserFactory()
|
||||
|
||||
ride = RideService.create_ride(
|
||||
name="Test Ride",
|
||||
park_id=park.pk,
|
||||
description="A test ride",
|
||||
status="OPERATING",
|
||||
category="TR",
|
||||
created_by=user,
|
||||
)
|
||||
|
||||
assert ride.name == "Test Ride"
|
||||
assert ride.park == park
|
||||
assert ride.description == "A test ride"
|
||||
assert ride.status == "OPERATING"
|
||||
assert ride.category == "TR"
|
||||
|
||||
def test__create_ride__with_manufacturer__sets_manufacturer(self):
|
||||
"""Test create_ride sets manufacturer when provided."""
|
||||
park = ParkFactory()
|
||||
manufacturer = ManufacturerCompanyFactory()
|
||||
|
||||
ride = RideService.create_ride(
|
||||
name="Test Ride",
|
||||
park_id=park.pk,
|
||||
category="RC",
|
||||
manufacturer_id=manufacturer.pk,
|
||||
)
|
||||
|
||||
assert ride.manufacturer == manufacturer
|
||||
|
||||
def test__create_ride__with_designer__sets_designer(self):
|
||||
"""Test create_ride sets designer when provided."""
|
||||
park = ParkFactory()
|
||||
designer = DesignerCompanyFactory()
|
||||
|
||||
ride = RideService.create_ride(
|
||||
name="Test Ride",
|
||||
park_id=park.pk,
|
||||
category="RC",
|
||||
designer_id=designer.pk,
|
||||
)
|
||||
|
||||
assert ride.designer == designer
|
||||
|
||||
def test__create_ride__with_ride_model__sets_ride_model(self):
|
||||
"""Test create_ride sets ride model when provided."""
|
||||
park = ParkFactory()
|
||||
ride_model = RideModelFactory()
|
||||
|
||||
ride = RideService.create_ride(
|
||||
name="Test Ride",
|
||||
park_id=park.pk,
|
||||
category="RC",
|
||||
ride_model_id=ride_model.pk,
|
||||
)
|
||||
|
||||
assert ride.ride_model == ride_model
|
||||
|
||||
def test__create_ride__with_park_area__sets_park_area(self):
|
||||
"""Test create_ride sets park area when provided."""
|
||||
park = ParkFactory()
|
||||
area = ParkAreaFactory(park=park)
|
||||
|
||||
ride = RideService.create_ride(
|
||||
name="Test Ride",
|
||||
park_id=park.pk,
|
||||
category="TR",
|
||||
park_area_id=area.pk,
|
||||
)
|
||||
|
||||
assert ride.park_area == area
|
||||
|
||||
def test__create_ride__invalid_park__raises_exception(self):
|
||||
"""Test create_ride raises exception for invalid park."""
|
||||
with pytest.raises(Exception):
|
||||
RideService.create_ride(
|
||||
name="Test Ride",
|
||||
park_id=99999, # Non-existent
|
||||
category="TR",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideServiceUpdateRide(TestCase):
|
||||
"""Tests for RideService.update_ride."""
|
||||
|
||||
def test__update_ride__valid_updates__updates_ride(self):
|
||||
"""Test update_ride updates ride with valid data."""
|
||||
ride = RideFactory(name="Original Name", description="Original desc")
|
||||
|
||||
updated_ride = RideService.update_ride(
|
||||
ride_id=ride.pk,
|
||||
updates={"name": "Updated Name", "description": "Updated desc"},
|
||||
)
|
||||
|
||||
assert updated_ride.name == "Updated Name"
|
||||
assert updated_ride.description == "Updated desc"
|
||||
|
||||
def test__update_ride__partial_updates__updates_only_specified_fields(self):
|
||||
"""Test update_ride only updates specified fields."""
|
||||
ride = RideFactory(name="Original", status="OPERATING")
|
||||
|
||||
updated_ride = RideService.update_ride(
|
||||
ride_id=ride.pk,
|
||||
updates={"name": "New Name"},
|
||||
)
|
||||
|
||||
assert updated_ride.name == "New Name"
|
||||
assert updated_ride.status == "OPERATING" # Unchanged
|
||||
|
||||
def test__update_ride__nonexistent_ride__raises_exception(self):
|
||||
"""Test update_ride raises exception for non-existent ride."""
|
||||
with pytest.raises(Ride.DoesNotExist):
|
||||
RideService.update_ride(
|
||||
ride_id=99999,
|
||||
updates={"name": "New Name"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideServiceCloseRideTemporarily(TestCase):
|
||||
"""Tests for RideService.close_ride_temporarily."""
|
||||
|
||||
def test__close_ride_temporarily__operating_ride__changes_status(self):
|
||||
"""Test close_ride_temporarily changes status to CLOSED_TEMP."""
|
||||
ride = RideFactory(status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
result = RideService.close_ride_temporarily(ride_id=ride.pk, user=user)
|
||||
|
||||
assert result.status == "CLOSED_TEMP"
|
||||
|
||||
def test__close_ride_temporarily__nonexistent_ride__raises_exception(self):
|
||||
"""Test close_ride_temporarily raises exception for non-existent ride."""
|
||||
with pytest.raises(Ride.DoesNotExist):
|
||||
RideService.close_ride_temporarily(ride_id=99999)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideServiceMarkRideSBNO(TestCase):
|
||||
"""Tests for RideService.mark_ride_sbno."""
|
||||
|
||||
def test__mark_ride_sbno__operating_ride__changes_status(self):
|
||||
"""Test mark_ride_sbno changes status to SBNO."""
|
||||
ride = RideFactory(status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
result = RideService.mark_ride_sbno(ride_id=ride.pk, user=user)
|
||||
|
||||
assert result.status == "SBNO"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideServiceScheduleRideClosing(TestCase):
|
||||
"""Tests for RideService.schedule_ride_closing."""
|
||||
|
||||
def test__schedule_ride_closing__valid_data__schedules_closing(self):
|
||||
"""Test schedule_ride_closing schedules ride closing."""
|
||||
from datetime import date, timedelta
|
||||
|
||||
ride = RideFactory(status="OPERATING")
|
||||
user = UserFactory()
|
||||
closing_date = date.today() + timedelta(days=30)
|
||||
|
||||
result = RideService.schedule_ride_closing(
|
||||
ride_id=ride.pk,
|
||||
closing_date=closing_date,
|
||||
post_closing_status="DEMOLISHED",
|
||||
user=user,
|
||||
)
|
||||
|
||||
assert result.status == "CLOSING"
|
||||
assert result.closing_date == closing_date
|
||||
assert result.post_closing_status == "DEMOLISHED"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideServiceCloseRidePermanently(TestCase):
|
||||
"""Tests for RideService.close_ride_permanently."""
|
||||
|
||||
def test__close_ride_permanently__operating_ride__changes_status(self):
|
||||
"""Test close_ride_permanently changes status to CLOSED_PERM."""
|
||||
ride = RideFactory(status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
result = RideService.close_ride_permanently(ride_id=ride.pk, user=user)
|
||||
|
||||
assert result.status == "CLOSED_PERM"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideServiceDemolishRide(TestCase):
|
||||
"""Tests for RideService.demolish_ride."""
|
||||
|
||||
def test__demolish_ride__closed_ride__changes_status(self):
|
||||
"""Test demolish_ride changes status to DEMOLISHED."""
|
||||
ride = RideFactory(status="CLOSED_PERM")
|
||||
user = UserFactory()
|
||||
|
||||
result = RideService.demolish_ride(ride_id=ride.pk, user=user)
|
||||
|
||||
assert result.status == "DEMOLISHED"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideServiceRelocateRide(TestCase):
|
||||
"""Tests for RideService.relocate_ride."""
|
||||
|
||||
def test__relocate_ride__valid_data__relocates_ride(self):
|
||||
"""Test relocate_ride moves ride to new park."""
|
||||
old_park = ParkFactory()
|
||||
new_park = ParkFactory()
|
||||
ride = RideFactory(park=old_park, status="OPERATING")
|
||||
user = UserFactory()
|
||||
|
||||
result = RideService.relocate_ride(
|
||||
ride_id=ride.pk,
|
||||
new_park_id=new_park.pk,
|
||||
user=user,
|
||||
)
|
||||
|
||||
assert result.park == new_park
|
||||
assert result.status == "RELOCATED"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestRideServiceReopenRide(TestCase):
|
||||
"""Tests for RideService.reopen_ride."""
|
||||
|
||||
def test__reopen_ride__closed_temp_ride__changes_status(self):
|
||||
"""Test reopen_ride changes status to OPERATING."""
|
||||
ride = RideFactory(status="CLOSED_TEMP")
|
||||
user = UserFactory()
|
||||
|
||||
result = RideService.reopen_ride(ride_id=ride.pk, user=user)
|
||||
|
||||
assert result.status == "OPERATING"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
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
|
||||
):
|
||||
"""Test handle_new_entity_suggestions creates submission for new manufacturer."""
|
||||
mock_submission = Mock()
|
||||
mock_submission.id = 1
|
||||
mock_create_submission.return_value = mock_submission
|
||||
|
||||
user = UserFactory()
|
||||
form_data = {
|
||||
"manufacturer_search": "New Manufacturer",
|
||||
"manufacturer": None,
|
||||
"designer_search": "",
|
||||
"designer": None,
|
||||
"ride_model_search": "",
|
||||
"ride_model": None,
|
||||
}
|
||||
|
||||
result = RideService.handle_new_entity_suggestions(
|
||||
form_data=form_data,
|
||||
submitter=user,
|
||||
)
|
||||
|
||||
assert result["total_submissions"] == 1
|
||||
assert 1 in result["manufacturers"]
|
||||
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
|
||||
):
|
||||
"""Test handle_new_entity_suggestions creates submission for new designer."""
|
||||
mock_submission = Mock()
|
||||
mock_submission.id = 2
|
||||
mock_create_submission.return_value = mock_submission
|
||||
|
||||
user = UserFactory()
|
||||
form_data = {
|
||||
"manufacturer_search": "",
|
||||
"manufacturer": None,
|
||||
"designer_search": "New Designer",
|
||||
"designer": None,
|
||||
"ride_model_search": "",
|
||||
"ride_model": None,
|
||||
}
|
||||
|
||||
result = RideService.handle_new_entity_suggestions(
|
||||
form_data=form_data,
|
||||
submitter=user,
|
||||
)
|
||||
|
||||
assert result["total_submissions"] == 1
|
||||
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
|
||||
):
|
||||
"""Test handle_new_entity_suggestions creates submission for new ride model."""
|
||||
mock_submission = Mock()
|
||||
mock_submission.id = 3
|
||||
mock_create_submission.return_value = mock_submission
|
||||
|
||||
user = UserFactory()
|
||||
manufacturer = ManufacturerCompanyFactory()
|
||||
form_data = {
|
||||
"manufacturer_search": "",
|
||||
"manufacturer": manufacturer,
|
||||
"designer_search": "",
|
||||
"designer": None,
|
||||
"ride_model_search": "New Model",
|
||||
"ride_model": None,
|
||||
}
|
||||
|
||||
result = RideService.handle_new_entity_suggestions(
|
||||
form_data=form_data,
|
||||
submitter=user,
|
||||
)
|
||||
|
||||
assert result["total_submissions"] == 1
|
||||
assert 3 in result["ride_models"]
|
||||
|
||||
def test__handle_new_entity_suggestions__no_new_entities__returns_empty(self):
|
||||
"""Test handle_new_entity_suggestions with no new entities returns empty result."""
|
||||
user = UserFactory()
|
||||
manufacturer = ManufacturerCompanyFactory()
|
||||
form_data = {
|
||||
"manufacturer_search": "Existing Mfr",
|
||||
"manufacturer": manufacturer, # Already selected
|
||||
"designer_search": "",
|
||||
"designer": None,
|
||||
"ride_model_search": "",
|
||||
"ride_model": None,
|
||||
}
|
||||
|
||||
result = RideService.handle_new_entity_suggestions(
|
||||
form_data=form_data,
|
||||
submitter=user,
|
||||
)
|
||||
|
||||
assert result["total_submissions"] == 0
|
||||
assert len(result["manufacturers"]) == 0
|
||||
assert len(result["designers"]) == 0
|
||||
assert len(result["ride_models"]) == 0
|
||||
332
backend/tests/services/test_user_deletion_service.py
Normal file
332
backend/tests/services/test_user_deletion_service.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
Tests for UserDeletionService and AccountService.
|
||||
|
||||
Following Django styleguide pattern: test__<context>__<action>__<expected_outcome>
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.test import TestCase, RequestFactory
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.services import UserDeletionService, AccountService
|
||||
from apps.accounts.models import User
|
||||
|
||||
from tests.factories import (
|
||||
UserFactory,
|
||||
StaffUserFactory,
|
||||
SuperUserFactory,
|
||||
ParkReviewFactory,
|
||||
RideReviewFactory,
|
||||
ParkFactory,
|
||||
RideFactory,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserDeletionServiceGetOrCreateDeletedUser(TestCase):
|
||||
"""Tests for UserDeletionService.get_or_create_deleted_user."""
|
||||
|
||||
def test__get_or_create_deleted_user__first_call__creates_user(self):
|
||||
"""Test get_or_create_deleted_user creates deleted user placeholder."""
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
assert deleted_user.username == UserDeletionService.DELETED_USER_USERNAME
|
||||
assert deleted_user.email == UserDeletionService.DELETED_USER_EMAIL
|
||||
assert deleted_user.is_active is False
|
||||
assert deleted_user.is_banned is True
|
||||
|
||||
def test__get_or_create_deleted_user__second_call__returns_existing(self):
|
||||
"""Test get_or_create_deleted_user returns existing user on subsequent calls."""
|
||||
first_call = UserDeletionService.get_or_create_deleted_user()
|
||||
second_call = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
assert first_call.pk == second_call.pk
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserDeletionServiceCanDeleteUser(TestCase):
|
||||
"""Tests for UserDeletionService.can_delete_user."""
|
||||
|
||||
def test__can_delete_user__regular_user__returns_true(self):
|
||||
"""Test can_delete_user returns True for regular user."""
|
||||
user = UserFactory()
|
||||
|
||||
can_delete, reason = UserDeletionService.can_delete_user(user)
|
||||
|
||||
assert can_delete is True
|
||||
assert reason is None
|
||||
|
||||
def test__can_delete_user__superuser__returns_false(self):
|
||||
"""Test can_delete_user returns False for superuser."""
|
||||
user = SuperUserFactory()
|
||||
|
||||
can_delete, reason = UserDeletionService.can_delete_user(user)
|
||||
|
||||
assert can_delete is False
|
||||
assert "superuser" in reason.lower()
|
||||
|
||||
def test__can_delete_user__deleted_user_placeholder__returns_false(self):
|
||||
"""Test can_delete_user returns False for deleted user placeholder."""
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
can_delete, reason = UserDeletionService.can_delete_user(deleted_user)
|
||||
|
||||
assert can_delete is False
|
||||
assert "placeholder" in reason.lower()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserDeletionServiceDeleteUserPreserveSubmissions(TestCase):
|
||||
"""Tests for UserDeletionService.delete_user_preserve_submissions."""
|
||||
|
||||
def test__delete_user_preserve_submissions__user_with_reviews__preserves_reviews(self):
|
||||
"""Test delete_user_preserve_submissions preserves user's reviews."""
|
||||
user = UserFactory()
|
||||
park = ParkFactory()
|
||||
ride = RideFactory()
|
||||
|
||||
# Create reviews
|
||||
park_review = ParkReviewFactory(user=user, park=park)
|
||||
ride_review = RideReviewFactory(user=user, ride=ride)
|
||||
|
||||
user_pk = user.pk
|
||||
|
||||
result = UserDeletionService.delete_user_preserve_submissions(user)
|
||||
|
||||
# User should be deleted
|
||||
assert not User.objects.filter(pk=user_pk).exists()
|
||||
|
||||
# Reviews should still exist
|
||||
park_review.refresh_from_db()
|
||||
ride_review.refresh_from_db()
|
||||
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
assert park_review.user == deleted_user
|
||||
assert ride_review.user == deleted_user
|
||||
|
||||
def test__delete_user_preserve_submissions__returns_summary(self):
|
||||
"""Test delete_user_preserve_submissions returns correct summary."""
|
||||
user = UserFactory()
|
||||
park = ParkFactory()
|
||||
ParkReviewFactory(user=user, park=park)
|
||||
|
||||
result = UserDeletionService.delete_user_preserve_submissions(user)
|
||||
|
||||
assert "deleted_user" in result
|
||||
assert "preserved_submissions" in result
|
||||
assert "transferred_to" in result
|
||||
assert result["preserved_submissions"]["park_reviews"] == 1
|
||||
|
||||
def test__delete_user_preserve_submissions__deleted_user_placeholder__raises_error(self):
|
||||
"""Test delete_user_preserve_submissions raises error for placeholder."""
|
||||
deleted_user = UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
UserDeletionService.delete_user_preserve_submissions(deleted_user)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAccountServiceValidatePassword(TestCase):
|
||||
"""Tests for AccountService.validate_password."""
|
||||
|
||||
def test__validate_password__valid_password__returns_true(self):
|
||||
"""Test validate_password returns True for valid password."""
|
||||
result = AccountService.validate_password("SecurePass123")
|
||||
|
||||
assert result is True
|
||||
|
||||
def test__validate_password__too_short__returns_false(self):
|
||||
"""Test validate_password returns False for short password."""
|
||||
result = AccountService.validate_password("Short1")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test__validate_password__no_uppercase__returns_false(self):
|
||||
"""Test validate_password returns False for password without uppercase."""
|
||||
result = AccountService.validate_password("lowercase123")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test__validate_password__no_lowercase__returns_false(self):
|
||||
"""Test validate_password returns False for password without lowercase."""
|
||||
result = AccountService.validate_password("UPPERCASE123")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test__validate_password__no_numbers__returns_false(self):
|
||||
"""Test validate_password returns False for password without numbers."""
|
||||
result = AccountService.validate_password("NoNumbers")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAccountServiceChangePassword(TestCase):
|
||||
"""Tests for AccountService.change_password."""
|
||||
|
||||
def test__change_password__correct_old_password__changes_password(self):
|
||||
"""Test change_password changes password with correct old password."""
|
||||
user = UserFactory()
|
||||
user.set_password("OldPassword123")
|
||||
user.save()
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.post("/change-password/")
|
||||
request.user = user
|
||||
request.session = {}
|
||||
|
||||
with patch.object(AccountService, "_send_password_change_confirmation"):
|
||||
result = AccountService.change_password(
|
||||
user=user,
|
||||
old_password="OldPassword123",
|
||||
new_password="NewPassword456",
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
user.refresh_from_db()
|
||||
assert user.check_password("NewPassword456")
|
||||
|
||||
def test__change_password__incorrect_old_password__returns_error(self):
|
||||
"""Test change_password returns error with incorrect old password."""
|
||||
user = UserFactory()
|
||||
user.set_password("CorrectPassword123")
|
||||
user.save()
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.post("/change-password/")
|
||||
request.user = user
|
||||
|
||||
result = AccountService.change_password(
|
||||
user=user,
|
||||
old_password="WrongPassword123",
|
||||
new_password="NewPassword456",
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "incorrect" in result["message"].lower()
|
||||
|
||||
def test__change_password__weak_new_password__returns_error(self):
|
||||
"""Test change_password returns error with weak new password."""
|
||||
user = UserFactory()
|
||||
user.set_password("OldPassword123")
|
||||
user.save()
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.post("/change-password/")
|
||||
request.user = user
|
||||
|
||||
result = AccountService.change_password(
|
||||
user=user,
|
||||
old_password="OldPassword123",
|
||||
new_password="weak", # Too weak
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "8 characters" in result["message"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAccountServiceInitiateEmailChange(TestCase):
|
||||
"""Tests for AccountService.initiate_email_change."""
|
||||
|
||||
@patch("apps.accounts.services.AccountService._send_email_verification")
|
||||
def test__initiate_email_change__valid_email__initiates_change(self, mock_send):
|
||||
"""Test initiate_email_change initiates email change for valid email."""
|
||||
user = UserFactory()
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.post("/change-email/")
|
||||
|
||||
result = AccountService.initiate_email_change(
|
||||
user=user,
|
||||
new_email="newemail@example.com",
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
user.refresh_from_db()
|
||||
assert user.pending_email == "newemail@example.com"
|
||||
mock_send.assert_called_once()
|
||||
|
||||
def test__initiate_email_change__empty_email__returns_error(self):
|
||||
"""Test initiate_email_change returns error for empty email."""
|
||||
user = UserFactory()
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.post("/change-email/")
|
||||
|
||||
result = AccountService.initiate_email_change(
|
||||
user=user,
|
||||
new_email="",
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "required" in result["message"].lower()
|
||||
|
||||
def test__initiate_email_change__duplicate_email__returns_error(self):
|
||||
"""Test initiate_email_change returns error for duplicate email."""
|
||||
existing_user = UserFactory(email="existing@example.com")
|
||||
user = UserFactory()
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.post("/change-email/")
|
||||
|
||||
result = AccountService.initiate_email_change(
|
||||
user=user,
|
||||
new_email="existing@example.com",
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "already in use" in result["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserDeletionServiceRequestUserDeletion(TestCase):
|
||||
"""Tests for UserDeletionService.request_user_deletion."""
|
||||
|
||||
@patch("apps.accounts.services.UserDeletionService.send_deletion_verification_email")
|
||||
def test__request_user_deletion__regular_user__creates_request(self, mock_send):
|
||||
"""Test request_user_deletion creates deletion request for regular user."""
|
||||
user = UserFactory()
|
||||
|
||||
deletion_request = UserDeletionService.request_user_deletion(user)
|
||||
|
||||
assert deletion_request.user == user
|
||||
assert deletion_request.verification_code is not None
|
||||
mock_send.assert_called_once()
|
||||
|
||||
def test__request_user_deletion__superuser__raises_error(self):
|
||||
"""Test request_user_deletion raises error for superuser."""
|
||||
user = SuperUserFactory()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
UserDeletionService.request_user_deletion(user)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserDeletionServiceCancelDeletionRequest(TestCase):
|
||||
"""Tests for UserDeletionService.cancel_deletion_request."""
|
||||
|
||||
@patch("apps.accounts.services.UserDeletionService.send_deletion_verification_email")
|
||||
def test__cancel_deletion_request__existing_request__cancels_it(self, mock_send):
|
||||
"""Test cancel_deletion_request cancels existing request."""
|
||||
user = UserFactory()
|
||||
UserDeletionService.request_user_deletion(user)
|
||||
|
||||
result = UserDeletionService.cancel_deletion_request(user)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test__cancel_deletion_request__no_request__returns_false(self):
|
||||
"""Test cancel_deletion_request returns False when no request exists."""
|
||||
user = UserFactory()
|
||||
|
||||
result = UserDeletionService.cancel_deletion_request(user)
|
||||
|
||||
assert result is False
|
||||
1
backend/tests/ux/__init__.py
Normal file
1
backend/tests/ux/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# UX Component Tests
|
||||
193
backend/tests/ux/test_breadcrumbs.py
Normal file
193
backend/tests/ux/test_breadcrumbs.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Tests for breadcrumb utilities.
|
||||
|
||||
These tests verify that the breadcrumb system generates
|
||||
correct navigation structures and Schema.org markup.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.core.utils.breadcrumbs import (
|
||||
Breadcrumb,
|
||||
BreadcrumbBuilder,
|
||||
build_breadcrumb,
|
||||
)
|
||||
|
||||
|
||||
class TestBreadcrumb:
|
||||
"""Tests for Breadcrumb dataclass."""
|
||||
|
||||
def test_basic_breadcrumb(self):
|
||||
"""Should create breadcrumb with required fields."""
|
||||
crumb = Breadcrumb(label="Home", url="/")
|
||||
assert crumb.label == "Home"
|
||||
assert crumb.url == "/"
|
||||
assert crumb.icon is None
|
||||
assert crumb.is_current is False
|
||||
|
||||
def test_breadcrumb_with_icon(self):
|
||||
"""Should accept icon parameter."""
|
||||
crumb = Breadcrumb(label="Home", url="/", icon="fas fa-home")
|
||||
assert crumb.icon == "fas fa-home"
|
||||
|
||||
def test_current_breadcrumb(self):
|
||||
"""Should mark breadcrumb as current."""
|
||||
crumb = Breadcrumb(label="Current Page", is_current=True)
|
||||
assert crumb.is_current is True
|
||||
assert crumb.url is None
|
||||
|
||||
def test_schema_position(self):
|
||||
"""Should have default schema position."""
|
||||
crumb = Breadcrumb(label="Test")
|
||||
assert crumb.schema_position == 1
|
||||
|
||||
|
||||
class TestBuildBreadcrumb:
|
||||
"""Tests for build_breadcrumb helper function."""
|
||||
|
||||
def test_basic_breadcrumb(self):
|
||||
"""Should create breadcrumb dict with defaults."""
|
||||
crumb = build_breadcrumb("Home", "/")
|
||||
assert crumb["label"] == "Home"
|
||||
assert crumb["url"] == "/"
|
||||
assert crumb["is_current"] is False
|
||||
|
||||
def test_current_breadcrumb(self):
|
||||
"""Should mark as current when specified."""
|
||||
crumb = build_breadcrumb("Current", is_current=True)
|
||||
assert crumb["is_current"] is True
|
||||
|
||||
def test_breadcrumb_with_icon(self):
|
||||
"""Should include icon when specified."""
|
||||
crumb = build_breadcrumb("Home", "/", icon="fas fa-home")
|
||||
assert crumb["icon"] == "fas fa-home"
|
||||
|
||||
|
||||
class TestBreadcrumbBuilder:
|
||||
"""Tests for BreadcrumbBuilder class."""
|
||||
|
||||
def test_empty_builder(self):
|
||||
"""Should build empty list when no crumbs added."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = builder.build()
|
||||
assert crumbs == []
|
||||
|
||||
def test_add_home(self):
|
||||
"""Should add home breadcrumb with defaults."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = builder.add_home().build()
|
||||
|
||||
assert len(crumbs) == 1
|
||||
assert crumbs[0].label == "Home"
|
||||
assert crumbs[0].url == "/"
|
||||
assert crumbs[0].icon == "fas fa-home"
|
||||
|
||||
def test_add_home_custom(self):
|
||||
"""Should allow customizing home breadcrumb."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = builder.add_home(
|
||||
label="Dashboard",
|
||||
url="/dashboard/",
|
||||
icon="fas fa-tachometer-alt",
|
||||
).build()
|
||||
|
||||
assert crumbs[0].label == "Dashboard"
|
||||
assert crumbs[0].url == "/dashboard/"
|
||||
assert crumbs[0].icon == "fas fa-tachometer-alt"
|
||||
|
||||
def test_add_breadcrumb(self):
|
||||
"""Should add breadcrumb with label and URL."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = builder.add("Parks", "/parks/").build()
|
||||
|
||||
assert len(crumbs) == 1
|
||||
assert crumbs[0].label == "Parks"
|
||||
assert crumbs[0].url == "/parks/"
|
||||
|
||||
def test_add_current(self):
|
||||
"""Should add current page breadcrumb."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = builder.add_current("Current Page").build()
|
||||
|
||||
assert len(crumbs) == 1
|
||||
assert crumbs[0].label == "Current Page"
|
||||
assert crumbs[0].is_current is True
|
||||
assert crumbs[0].url is None
|
||||
|
||||
def test_add_current_with_icon(self):
|
||||
"""Should add current page with icon."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = builder.add_current("Settings", icon="fas fa-cog").build()
|
||||
|
||||
assert crumbs[0].icon == "fas fa-cog"
|
||||
|
||||
def test_chain_multiple_breadcrumbs(self):
|
||||
"""Should chain multiple breadcrumbs."""
|
||||
builder = BreadcrumbBuilder()
|
||||
crumbs = (
|
||||
builder.add_home()
|
||||
.add("Parks", "/parks/")
|
||||
.add("California", "/parks/california/")
|
||||
.add_current("Disneyland")
|
||||
.build()
|
||||
)
|
||||
|
||||
assert len(crumbs) == 4
|
||||
assert crumbs[0].label == "Home"
|
||||
assert crumbs[1].label == "Parks"
|
||||
assert crumbs[2].label == "California"
|
||||
assert crumbs[3].label == "Disneyland"
|
||||
assert crumbs[3].is_current is True
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
assert crumbs[0].schema_position == 1
|
||||
assert crumbs[1].schema_position == 2
|
||||
assert crumbs[2].schema_position == 3
|
||||
|
||||
def test_builder_is_reusable(self):
|
||||
"""Builder should be reusable after build."""
|
||||
builder = BreadcrumbBuilder()
|
||||
builder.add_home()
|
||||
|
||||
crumbs1 = builder.build()
|
||||
builder.add("New", "/new/")
|
||||
crumbs2 = builder.build()
|
||||
|
||||
assert len(crumbs1) == 1
|
||||
assert len(crumbs2) == 2
|
||||
|
||||
|
||||
class TestBreadcrumbContextProcessor:
|
||||
"""Tests for breadcrumb context processor."""
|
||||
|
||||
def test_empty_breadcrumbs_when_not_set(self):
|
||||
"""Should return empty list when not set on request."""
|
||||
from apps.core.context_processors import breadcrumbs
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
|
||||
context = breadcrumbs(request)
|
||||
assert context["breadcrumbs"] == []
|
||||
|
||||
def test_returns_breadcrumbs_from_request(self):
|
||||
"""Should return breadcrumbs when set on request."""
|
||||
from apps.core.context_processors import breadcrumbs
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
request.breadcrumbs = [
|
||||
build_breadcrumb("Home", "/"),
|
||||
build_breadcrumb("Test", is_current=True),
|
||||
]
|
||||
|
||||
context = breadcrumbs(request)
|
||||
assert len(context["breadcrumbs"]) == 2
|
||||
357
backend/tests/ux/test_components.py
Normal file
357
backend/tests/ux/test_components.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Tests for UX component templates.
|
||||
|
||||
These tests verify that component templates render correctly
|
||||
with various parameter combinations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.template import Context, Template
|
||||
from django.test import RequestFactory, override_settings
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestPageHeaderComponent:
|
||||
"""Tests for page_header.html component."""
|
||||
|
||||
def test_renders_title(self):
|
||||
"""Should render title text."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/layout/page_header.html' with title='Test Title' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Test Title" in html
|
||||
|
||||
def test_renders_subtitle(self):
|
||||
"""Should render subtitle when provided."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/layout/page_header.html' with
|
||||
title='Title'
|
||||
subtitle='Subtitle text'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Subtitle text" in html
|
||||
|
||||
def test_renders_icon(self):
|
||||
"""Should render icon when provided."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/layout/page_header.html' with
|
||||
title='Title'
|
||||
icon='fas fa-star'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "fas fa-star" in html
|
||||
|
||||
def test_renders_primary_action(self):
|
||||
"""Should render primary action button."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/layout/page_header.html' with
|
||||
title='Title'
|
||||
primary_action_url='/create/'
|
||||
primary_action_text='Create'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Create" in html
|
||||
assert "/create/" in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestActionBarComponent:
|
||||
"""Tests for action_bar.html component."""
|
||||
|
||||
def test_renders_primary_action(self):
|
||||
"""Should render primary action button."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/ui/action_bar.html' with
|
||||
primary_action_text='Save'
|
||||
primary_action_url='/save/'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Save" in html
|
||||
assert "/save/" in html
|
||||
|
||||
def test_renders_secondary_action(self):
|
||||
"""Should render secondary action button."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/ui/action_bar.html' with
|
||||
secondary_action_text='Preview'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Preview" in html
|
||||
|
||||
def test_renders_tertiary_action(self):
|
||||
"""Should render tertiary action button."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/ui/action_bar.html' with
|
||||
tertiary_action_text='Cancel'
|
||||
tertiary_action_url='/back/'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Cancel" in html
|
||||
assert "/back/" in html
|
||||
|
||||
def test_alignment_classes(self):
|
||||
"""Should apply correct alignment classes."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/ui/action_bar.html' with
|
||||
align='between'
|
||||
primary_action_text='Save'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "justify-between" in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSkeletonComponents:
|
||||
"""Tests for skeleton screen components."""
|
||||
|
||||
def test_list_skeleton_renders(self):
|
||||
"""Should render list skeleton with specified rows."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/skeletons/list_skeleton.html' with rows=3 %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "animate-pulse" in html
|
||||
|
||||
def test_card_grid_skeleton_renders(self):
|
||||
"""Should render card grid skeleton."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/skeletons/card_grid_skeleton.html' with cards=4 %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "animate-pulse" in html
|
||||
|
||||
def test_detail_skeleton_renders(self):
|
||||
"""Should render detail skeleton."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/skeletons/detail_skeleton.html' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "animate-pulse" in html
|
||||
|
||||
def test_form_skeleton_renders(self):
|
||||
"""Should render form skeleton."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/skeletons/form_skeleton.html' with fields=3 %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "animate-pulse" in html
|
||||
|
||||
def test_table_skeleton_renders(self):
|
||||
"""Should render table skeleton."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/skeletons/table_skeleton.html' with rows=5 columns=4 %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "animate-pulse" in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestModalComponents:
|
||||
"""Tests for modal components."""
|
||||
|
||||
def test_modal_base_renders(self):
|
||||
"""Should render modal base structure."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/modals/modal_base.html' with
|
||||
modal_id='test-modal'
|
||||
show_var='showModal'
|
||||
title='Test Modal'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "test-modal" in html
|
||||
assert "Test Modal" in html
|
||||
assert "showModal" in html
|
||||
|
||||
def test_modal_confirm_renders(self):
|
||||
"""Should render confirmation modal."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/modals/modal_confirm.html' with
|
||||
modal_id='confirm-modal'
|
||||
show_var='showConfirm'
|
||||
title='Confirm Action'
|
||||
message='Are you sure?'
|
||||
confirm_text='Yes'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "confirm-modal" in html
|
||||
assert "Confirm Action" in html
|
||||
assert "Are you sure?" in html
|
||||
assert "Yes" in html
|
||||
|
||||
def test_modal_confirm_destructive_variant(self):
|
||||
"""Should apply destructive styling."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/modals/modal_confirm.html' with
|
||||
modal_id='delete-modal'
|
||||
show_var='showDelete'
|
||||
title='Delete'
|
||||
message='Delete this item?'
|
||||
confirm_variant='destructive'
|
||||
%}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "btn-destructive" in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBreadcrumbComponent:
|
||||
"""Tests for breadcrumb component."""
|
||||
|
||||
def test_renders_breadcrumbs(self):
|
||||
"""Should render breadcrumb navigation."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/navigation/breadcrumbs.html' %}
|
||||
"""
|
||||
)
|
||||
breadcrumbs = [
|
||||
{"label": "Home", "url": "/", "is_current": False},
|
||||
{"label": "Parks", "url": "/parks/", "is_current": False},
|
||||
{"label": "Test Park", "url": None, "is_current": True},
|
||||
]
|
||||
html = template.render(Context({"breadcrumbs": breadcrumbs}))
|
||||
|
||||
assert "Home" in html
|
||||
assert "Parks" in html
|
||||
assert "Test Park" in html
|
||||
|
||||
def test_renders_schema_org_markup(self):
|
||||
"""Should include Schema.org BreadcrumbList."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/navigation/breadcrumbs.html' %}
|
||||
"""
|
||||
)
|
||||
breadcrumbs = [
|
||||
{"label": "Home", "url": "/", "is_current": False, "schema_position": 1},
|
||||
{"label": "Test", "url": None, "is_current": True, "schema_position": 2},
|
||||
]
|
||||
html = template.render(Context({"breadcrumbs": breadcrumbs}))
|
||||
|
||||
assert "BreadcrumbList" in html
|
||||
|
||||
def test_empty_breadcrumbs(self):
|
||||
"""Should handle empty breadcrumbs gracefully."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/navigation/breadcrumbs.html' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({"breadcrumbs": []}))
|
||||
|
||||
# Should not error, may render nothing or empty nav
|
||||
assert html is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestStatusBadgeComponent:
|
||||
"""Tests for status badge component."""
|
||||
|
||||
def test_renders_status_text(self):
|
||||
"""Should render status label."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/status_badge.html' with status='published' label='Published' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Published" in html
|
||||
|
||||
def test_applies_status_colors(self):
|
||||
"""Should apply appropriate color classes for status."""
|
||||
# Test published/active status
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'components/status_badge.html' with status='published' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
# Should have some indication of success/green styling
|
||||
assert "green" in html.lower() or "success" in html.lower() or "published" in html.lower()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestLoadingIndicatorComponent:
|
||||
"""Tests for loading indicator component."""
|
||||
|
||||
def test_renders_loading_indicator(self):
|
||||
"""Should render loading indicator."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'htmx/components/loading_indicator.html' with text='Loading...' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "Loading" in html
|
||||
|
||||
def test_renders_with_id(self):
|
||||
"""Should render with specified ID for htmx-indicator."""
|
||||
template = Template(
|
||||
"""
|
||||
{% include 'htmx/components/loading_indicator.html' with id='my-loader' %}
|
||||
"""
|
||||
)
|
||||
html = template.render(Context({}))
|
||||
|
||||
assert "my-loader" in html
|
||||
282
backend/tests/ux/test_htmx_utils.py
Normal file
282
backend/tests/ux/test_htmx_utils.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Tests for HTMX utility functions.
|
||||
|
||||
These tests verify that the HTMX response helpers generate
|
||||
correct responses with proper headers and content.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.http import HttpRequest
|
||||
from django.test import RequestFactory
|
||||
|
||||
from apps.core.htmx_utils import (
|
||||
get_htmx_target,
|
||||
get_htmx_trigger,
|
||||
htmx_error,
|
||||
htmx_modal_close,
|
||||
htmx_redirect,
|
||||
htmx_refresh,
|
||||
htmx_refresh_section,
|
||||
htmx_success,
|
||||
htmx_trigger,
|
||||
htmx_validation_response,
|
||||
htmx_warning,
|
||||
is_htmx_request,
|
||||
)
|
||||
|
||||
|
||||
class TestIsHtmxRequest:
|
||||
"""Tests for is_htmx_request function."""
|
||||
|
||||
def test_returns_true_for_htmx_request(self):
|
||||
"""Should return True when HX-Request header is 'true'."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/", HTTP_HX_REQUEST="true")
|
||||
assert is_htmx_request(request) is True
|
||||
|
||||
def test_returns_false_for_regular_request(self):
|
||||
"""Should return False for regular requests without HTMX header."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
assert is_htmx_request(request) is False
|
||||
|
||||
def test_returns_false_for_wrong_value(self):
|
||||
"""Should return False when HX-Request header has wrong value."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/", HTTP_HX_REQUEST="false")
|
||||
assert is_htmx_request(request) is False
|
||||
|
||||
|
||||
class TestGetHtmxTarget:
|
||||
"""Tests for get_htmx_target function."""
|
||||
|
||||
def test_returns_target_when_present(self):
|
||||
"""Should return target ID when HX-Target header is present."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/", HTTP_HX_TARGET="my-target")
|
||||
assert get_htmx_target(request) == "my-target"
|
||||
|
||||
def test_returns_none_when_missing(self):
|
||||
"""Should return None when HX-Target header is missing."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
assert get_htmx_target(request) is None
|
||||
|
||||
|
||||
class TestGetHtmxTrigger:
|
||||
"""Tests for get_htmx_trigger function."""
|
||||
|
||||
def test_returns_trigger_when_present(self):
|
||||
"""Should return trigger ID when HX-Trigger header is present."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/", HTTP_HX_TRIGGER="my-button")
|
||||
assert get_htmx_trigger(request) == "my-button"
|
||||
|
||||
def test_returns_none_when_missing(self):
|
||||
"""Should return None when HX-Trigger header is missing."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
assert get_htmx_trigger(request) is None
|
||||
|
||||
|
||||
class TestHtmxRedirect:
|
||||
"""Tests for htmx_redirect function."""
|
||||
|
||||
def test_sets_redirect_header(self):
|
||||
"""Should set HX-Redirect header with correct URL."""
|
||||
response = htmx_redirect("/parks/")
|
||||
assert response["HX-Redirect"] == "/parks/"
|
||||
|
||||
def test_returns_empty_body(self):
|
||||
"""Should return empty response body."""
|
||||
response = htmx_redirect("/parks/")
|
||||
assert response.content == b""
|
||||
|
||||
|
||||
class TestHtmxTrigger:
|
||||
"""Tests for htmx_trigger function."""
|
||||
|
||||
def test_simple_trigger(self):
|
||||
"""Should set simple trigger name."""
|
||||
response = htmx_trigger("myEvent")
|
||||
assert response["HX-Trigger"] == "myEvent"
|
||||
|
||||
def test_trigger_with_payload(self):
|
||||
"""Should set trigger with JSON payload."""
|
||||
response = htmx_trigger("myEvent", {"key": "value"})
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
assert trigger_data == {"myEvent": {"key": "value"}}
|
||||
|
||||
|
||||
class TestHtmxRefresh:
|
||||
"""Tests for htmx_refresh function."""
|
||||
|
||||
def test_sets_refresh_header(self):
|
||||
"""Should set HX-Refresh header to 'true'."""
|
||||
response = htmx_refresh()
|
||||
assert response["HX-Refresh"] == "true"
|
||||
|
||||
|
||||
class TestHtmxSuccess:
|
||||
"""Tests for htmx_success function."""
|
||||
|
||||
def test_basic_success_message(self):
|
||||
"""Should create success response with toast trigger."""
|
||||
response = htmx_success("Item saved!")
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert "showToast" in trigger_data
|
||||
assert trigger_data["showToast"]["type"] == "success"
|
||||
assert trigger_data["showToast"]["message"] == "Item saved!"
|
||||
assert trigger_data["showToast"]["duration"] == 5000
|
||||
|
||||
def test_success_with_custom_duration(self):
|
||||
"""Should allow custom duration."""
|
||||
response = htmx_success("Quick message", duration=2000)
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["showToast"]["duration"] == 2000
|
||||
|
||||
def test_success_with_title(self):
|
||||
"""Should include title when provided."""
|
||||
response = htmx_success("Details here", title="Success!")
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["showToast"]["title"] == "Success!"
|
||||
|
||||
def test_success_with_action(self):
|
||||
"""Should include action button config."""
|
||||
response = htmx_success(
|
||||
"Item deleted",
|
||||
action={"label": "Undo", "onClick": "undoDelete()"},
|
||||
)
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["showToast"]["action"]["label"] == "Undo"
|
||||
assert trigger_data["showToast"]["action"]["onClick"] == "undoDelete()"
|
||||
|
||||
def test_success_with_html_content(self):
|
||||
"""Should include HTML in response body."""
|
||||
response = htmx_success("Done", html="<div>Updated</div>")
|
||||
assert response.content == b"<div>Updated</div>"
|
||||
|
||||
|
||||
class TestHtmxError:
|
||||
"""Tests for htmx_error function."""
|
||||
|
||||
def test_basic_error_message(self):
|
||||
"""Should create error response with toast trigger."""
|
||||
response = htmx_error("Something went wrong")
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert response.status_code == 400
|
||||
assert trigger_data["showToast"]["type"] == "error"
|
||||
assert trigger_data["showToast"]["message"] == "Something went wrong"
|
||||
assert trigger_data["showToast"]["duration"] == 0 # Persistent by default
|
||||
|
||||
def test_error_with_custom_status(self):
|
||||
"""Should allow custom HTTP status code."""
|
||||
response = htmx_error("Validation failed", status=422)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_error_with_retry_action(self):
|
||||
"""Should include retry action when requested."""
|
||||
response = htmx_error("Server error", show_retry=True)
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["showToast"]["action"]["label"] == "Retry"
|
||||
|
||||
|
||||
class TestHtmxWarning:
|
||||
"""Tests for htmx_warning function."""
|
||||
|
||||
def test_basic_warning_message(self):
|
||||
"""Should create warning response with toast trigger."""
|
||||
response = htmx_warning("Session expiring soon")
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["showToast"]["type"] == "warning"
|
||||
assert trigger_data["showToast"]["message"] == "Session expiring soon"
|
||||
assert trigger_data["showToast"]["duration"] == 8000
|
||||
|
||||
|
||||
class TestHtmxModalClose:
|
||||
"""Tests for htmx_modal_close function."""
|
||||
|
||||
def test_basic_modal_close(self):
|
||||
"""Should trigger closeModal event."""
|
||||
response = htmx_modal_close()
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["closeModal"] is True
|
||||
|
||||
def test_modal_close_with_message(self):
|
||||
"""Should include success toast when message provided."""
|
||||
response = htmx_modal_close(message="Saved successfully!")
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["closeModal"] is True
|
||||
assert trigger_data["showToast"]["message"] == "Saved successfully!"
|
||||
|
||||
def test_modal_close_with_refresh(self):
|
||||
"""Should include refresh section trigger."""
|
||||
response = htmx_modal_close(
|
||||
message="Done",
|
||||
refresh_target="#items-list",
|
||||
refresh_url="/items/",
|
||||
)
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["refreshSection"]["target"] == "#items-list"
|
||||
assert trigger_data["refreshSection"]["url"] == "/items/"
|
||||
|
||||
|
||||
class TestHtmxRefreshSection:
|
||||
"""Tests for htmx_refresh_section function."""
|
||||
|
||||
def test_sets_retarget_header(self):
|
||||
"""Should set HX-Retarget header."""
|
||||
response = htmx_refresh_section("#my-section", html="<div>New</div>")
|
||||
assert response["HX-Retarget"] == "#my-section"
|
||||
assert response["HX-Reswap"] == "innerHTML"
|
||||
|
||||
def test_includes_success_message(self):
|
||||
"""Should include toast when message provided."""
|
||||
response = htmx_refresh_section(
|
||||
"#my-section",
|
||||
html="<div>New</div>",
|
||||
message="Section updated",
|
||||
)
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
|
||||
assert trigger_data["showToast"]["message"] == "Section updated"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestHtmxValidationResponse:
|
||||
"""Tests for htmx_validation_response function."""
|
||||
|
||||
def test_validation_error_response(self):
|
||||
"""Should render error template with errors."""
|
||||
response = htmx_validation_response(
|
||||
"email",
|
||||
errors=["Invalid email format"],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
# Response should contain error markup
|
||||
|
||||
def test_validation_success_response(self):
|
||||
"""Should render success template with message."""
|
||||
response = htmx_validation_response(
|
||||
"username",
|
||||
success_message="Username available",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
# Response should contain success markup
|
||||
|
||||
def test_validation_neutral_response(self):
|
||||
"""Should render empty success when no errors or message."""
|
||||
response = htmx_validation_response("field")
|
||||
assert response.status_code == 200
|
||||
139
backend/tests/ux/test_messages.py
Normal file
139
backend/tests/ux/test_messages.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Tests for standardized message utilities.
|
||||
|
||||
These tests verify that message helper functions generate
|
||||
consistent, user-friendly messages.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.core.utils.messages import (
|
||||
confirm_delete,
|
||||
error_not_found,
|
||||
error_permission,
|
||||
error_validation,
|
||||
info_no_changes,
|
||||
success_created,
|
||||
success_deleted,
|
||||
success_updated,
|
||||
warning_unsaved,
|
||||
)
|
||||
|
||||
|
||||
class TestSuccessMessages:
|
||||
"""Tests for success message helpers."""
|
||||
|
||||
def test_success_created_basic(self):
|
||||
"""Should generate basic created message."""
|
||||
message = success_created("Park")
|
||||
assert "Park" in message
|
||||
assert "created" in message.lower()
|
||||
|
||||
def test_success_created_with_name(self):
|
||||
"""Should include object name when provided."""
|
||||
message = success_created("Park", "Disneyland")
|
||||
assert "Disneyland" in message
|
||||
|
||||
def test_success_created_custom(self):
|
||||
"""Should use custom message when provided."""
|
||||
message = success_created("Park", custom_message="Your park is ready!")
|
||||
assert message == "Your park is ready!"
|
||||
|
||||
def test_success_updated_basic(self):
|
||||
"""Should generate basic updated message."""
|
||||
message = success_updated("Park")
|
||||
assert "Park" in message
|
||||
assert "updated" in message.lower()
|
||||
|
||||
def test_success_updated_with_name(self):
|
||||
"""Should include object name when provided."""
|
||||
message = success_updated("Park", "Disneyland")
|
||||
assert "Disneyland" in message
|
||||
|
||||
def test_success_deleted_basic(self):
|
||||
"""Should generate basic deleted message."""
|
||||
message = success_deleted("Park")
|
||||
assert "Park" in message
|
||||
assert "deleted" in message.lower()
|
||||
|
||||
def test_success_deleted_with_name(self):
|
||||
"""Should include object name when provided."""
|
||||
message = success_deleted("Park", "Old Park")
|
||||
assert "Old Park" in message
|
||||
|
||||
|
||||
class TestErrorMessages:
|
||||
"""Tests for error message helpers."""
|
||||
|
||||
def test_error_validation_generic(self):
|
||||
"""Should generate generic validation error."""
|
||||
message = error_validation()
|
||||
assert "validation" in message.lower() or "invalid" in message.lower()
|
||||
|
||||
def test_error_validation_with_field(self):
|
||||
"""Should include field name when provided."""
|
||||
message = error_validation("email")
|
||||
assert "email" in message.lower()
|
||||
|
||||
def test_error_validation_custom(self):
|
||||
"""Should use custom message when provided."""
|
||||
message = error_validation(custom_message="Email format is invalid")
|
||||
assert message == "Email format is invalid"
|
||||
|
||||
def test_error_not_found_basic(self):
|
||||
"""Should generate not found message."""
|
||||
message = error_not_found("Park")
|
||||
assert "Park" in message
|
||||
assert "not found" in message.lower() or "could not" in message.lower()
|
||||
|
||||
def test_error_permission_basic(self):
|
||||
"""Should generate permission denied message."""
|
||||
message = error_permission()
|
||||
assert "permission" in message.lower() or "authorized" in message.lower()
|
||||
|
||||
def test_error_permission_with_action(self):
|
||||
"""Should include action when provided."""
|
||||
message = error_permission("delete this park")
|
||||
assert "delete" in message.lower()
|
||||
|
||||
|
||||
class TestWarningMessages:
|
||||
"""Tests for warning message helpers."""
|
||||
|
||||
def test_warning_unsaved(self):
|
||||
"""Should generate unsaved changes warning."""
|
||||
message = warning_unsaved()
|
||||
assert "unsaved" in message.lower() or "changes" in message.lower()
|
||||
|
||||
|
||||
class TestInfoMessages:
|
||||
"""Tests for info message helpers."""
|
||||
|
||||
def test_info_no_changes(self):
|
||||
"""Should generate no changes message."""
|
||||
message = info_no_changes()
|
||||
assert "no changes" in message.lower() or "nothing" in message.lower()
|
||||
|
||||
|
||||
class TestConfirmMessages:
|
||||
"""Tests for confirmation message helpers."""
|
||||
|
||||
def test_confirm_delete_basic(self):
|
||||
"""Should generate delete confirmation message."""
|
||||
message = confirm_delete("Park")
|
||||
assert "Park" in message
|
||||
assert "delete" in message.lower()
|
||||
|
||||
def test_confirm_delete_with_name(self):
|
||||
"""Should include object name when provided."""
|
||||
message = confirm_delete("Park", "Disneyland")
|
||||
assert "Disneyland" in message
|
||||
|
||||
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()
|
||||
)
|
||||
203
backend/tests/ux/test_meta.py
Normal file
203
backend/tests/ux/test_meta.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Tests for meta tag utilities.
|
||||
|
||||
These tests verify that meta tag helpers generate
|
||||
correct SEO and social sharing metadata.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.test import RequestFactory
|
||||
|
||||
from apps.core.utils.meta import (
|
||||
build_canonical_url,
|
||||
build_meta_context,
|
||||
generate_meta_description,
|
||||
get_og_image,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateMetaDescription:
|
||||
"""Tests for generate_meta_description function."""
|
||||
|
||||
def test_basic_text(self):
|
||||
"""Should return text as description."""
|
||||
description = generate_meta_description(text="This is a test description.")
|
||||
assert description == "This is a test description."
|
||||
|
||||
def test_truncates_long_text(self):
|
||||
"""Should truncate text longer than max_length."""
|
||||
long_text = "A" * 200
|
||||
description = generate_meta_description(text=long_text, max_length=160)
|
||||
assert len(description) <= 160
|
||||
assert description.endswith("...")
|
||||
|
||||
def test_custom_max_length(self):
|
||||
"""Should respect custom max_length."""
|
||||
text = "A" * 100
|
||||
description = generate_meta_description(text=text, max_length=50)
|
||||
assert len(description) <= 50
|
||||
|
||||
def test_strips_html(self):
|
||||
"""Should strip HTML tags from text."""
|
||||
html_text = "<p>This is <strong>bold</strong> text.</p>"
|
||||
description = generate_meta_description(text=html_text)
|
||||
assert "<p>" not in description
|
||||
assert "<strong>" not in description
|
||||
assert "bold" in description
|
||||
|
||||
def test_handles_none(self):
|
||||
"""Should return empty string for None input."""
|
||||
description = generate_meta_description(text=None)
|
||||
assert description == ""
|
||||
|
||||
def test_handles_empty_string(self):
|
||||
"""Should return empty string for empty input."""
|
||||
description = generate_meta_description(text="")
|
||||
assert description == ""
|
||||
|
||||
|
||||
class TestGetOgImage:
|
||||
"""Tests for get_og_image function."""
|
||||
|
||||
def test_returns_image_url(self):
|
||||
"""Should return provided image URL."""
|
||||
url = get_og_image(image_url="https://example.com/image.jpg")
|
||||
assert url == "https://example.com/image.jpg"
|
||||
|
||||
def test_returns_default_when_none(self):
|
||||
"""Should return default OG image when no image provided."""
|
||||
url = get_og_image()
|
||||
# Should return some default or empty string
|
||||
assert url is not None
|
||||
|
||||
def test_makes_url_absolute(self):
|
||||
"""Should convert relative URL to absolute when request provided."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
request.META["HTTP_HOST"] = "example.com"
|
||||
|
||||
url = get_og_image(image_url="/static/images/og.jpg", request=request)
|
||||
assert "example.com" in url or url.startswith("/")
|
||||
|
||||
|
||||
class TestBuildCanonicalUrl:
|
||||
"""Tests for build_canonical_url function."""
|
||||
|
||||
def test_returns_path(self):
|
||||
"""Should return provided path."""
|
||||
url = build_canonical_url(path="/parks/test/")
|
||||
assert "/parks/test/" in url
|
||||
|
||||
def test_builds_from_request(self):
|
||||
"""Should build URL from request."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/parks/")
|
||||
request.META["HTTP_HOST"] = "example.com"
|
||||
|
||||
url = build_canonical_url(request=request)
|
||||
assert "/parks/" in url
|
||||
|
||||
def test_handles_none(self):
|
||||
"""Should return empty string when nothing provided."""
|
||||
url = build_canonical_url()
|
||||
assert url == "" or url is not None
|
||||
|
||||
|
||||
class TestBuildMetaContext:
|
||||
"""Tests for build_meta_context function."""
|
||||
|
||||
def test_basic_meta_context(self):
|
||||
"""Should build basic meta context with title."""
|
||||
context = build_meta_context(title="Test Page")
|
||||
|
||||
assert "title" in context
|
||||
assert context["title"] == "Test Page"
|
||||
|
||||
def test_includes_description(self):
|
||||
"""Should include description when provided."""
|
||||
context = build_meta_context(
|
||||
title="Test Page",
|
||||
description="This is a test page description.",
|
||||
)
|
||||
|
||||
assert "description" in context
|
||||
assert context["description"] == "This is a test page description."
|
||||
|
||||
def test_includes_og_tags(self):
|
||||
"""Should include Open Graph tags."""
|
||||
context = build_meta_context(
|
||||
title="Test Page",
|
||||
description="Description here.",
|
||||
)
|
||||
|
||||
assert "og_title" in context or "title" in context
|
||||
assert "og_description" in context or "description" in context
|
||||
|
||||
def test_includes_canonical_url(self):
|
||||
"""Should include canonical URL when request provided."""
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/test/")
|
||||
request.META["HTTP_HOST"] = "example.com"
|
||||
|
||||
context = build_meta_context(
|
||||
title="Test",
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert "canonical_url" in context
|
||||
|
||||
def test_includes_og_image(self):
|
||||
"""Should include OG image when provided."""
|
||||
context = build_meta_context(
|
||||
title="Test",
|
||||
og_image="https://example.com/image.jpg",
|
||||
)
|
||||
|
||||
assert "og_image" in context
|
||||
assert context["og_image"] == "https://example.com/image.jpg"
|
||||
|
||||
def test_includes_og_type(self):
|
||||
"""Should include OG type."""
|
||||
context = build_meta_context(
|
||||
title="Test",
|
||||
og_type="article",
|
||||
)
|
||||
|
||||
assert "og_type" in context
|
||||
assert context["og_type"] == "article"
|
||||
|
||||
def test_default_og_type(self):
|
||||
"""Should default to 'website' for OG type."""
|
||||
context = build_meta_context(title="Test")
|
||||
|
||||
if "og_type" in context:
|
||||
assert context["og_type"] == "website"
|
||||
|
||||
|
||||
class TestMetaContextProcessor:
|
||||
"""Tests for page_meta context processor."""
|
||||
|
||||
def test_empty_meta_when_not_set(self):
|
||||
"""Should return empty dict when not set on request."""
|
||||
from apps.core.context_processors import page_meta
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
|
||||
context = page_meta(request)
|
||||
assert context["page_meta"] == {}
|
||||
|
||||
def test_returns_meta_from_request(self):
|
||||
"""Should return page_meta when set on request."""
|
||||
from apps.core.context_processors import page_meta
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.get("/")
|
||||
request.page_meta = {
|
||||
"title": "Test Page",
|
||||
"description": "Test description",
|
||||
}
|
||||
|
||||
context = page_meta(request)
|
||||
assert context["page_meta"]["title"] == "Test Page"
|
||||
assert context["page_meta"]["description"] == "Test description"
|
||||
Reference in New Issue
Block a user