Add test utilities and state machine diagrams for FSM models

- Introduced reusable test utilities in `backend/tests/utils` for FSM transitions, HTMX interactions, and common scenarios.
- Added factory functions for creating test submissions, parks, rides, and photo submissions.
- Implemented assertion helpers for verifying state changes, toast notifications, and transition logs.
- Created comprehensive state machine diagrams for all FSM-enabled models in `docs/STATE_DIAGRAMS.md`, detailing states, transitions, and guard conditions.
This commit is contained in:
pacnpal
2025-12-22 08:55:39 -05:00
parent b508434574
commit 45d97b6e68
71 changed files with 8608 additions and 633 deletions

View File

@@ -0,0 +1,6 @@
"""
Integration tests for ThrillWiki.
These tests verify that different components of the application work together correctly
without requiring a browser (unlike E2E tests).
"""

View File

@@ -0,0 +1,792 @@
"""
Integration Tests for FSMTransitionView
Tests the FSMTransitionView without a browser, using Django's test client.
These tests verify:
- FSMTransitionView handles HTMX requests correctly
- HX-Trigger headers contain proper toast data
- Correct partial templates rendered for each model
- Permission validation before transition execution
These are faster than E2E tests and don't require Playwright.
"""
import json
import pytest
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
User = get_user_model()
class TestFSMTransitionViewHTMX(TestCase):
"""Tests for FSMTransitionView with HTMX requests."""
@classmethod
def setUpTestData(cls):
"""Set up test data for all tests in this class."""
# Create regular user
cls.user = User.objects.create_user(
username="testuser",
email="testuser@example.com",
password="testpass123"
)
# Create moderator user
cls.moderator = User.objects.create_user(
username="moderator",
email="moderator@example.com",
password="modpass123",
is_staff=True
)
# Create admin user
cls.admin = User.objects.create_user(
username="admin",
email="admin@example.com",
password="adminpass123",
is_staff=True,
is_superuser=True
)
def setUp(self):
"""Set up test client for each test."""
self.client = Client()
def test_fsm_transition_view_with_htmx_header(self):
"""Test that FSMTransitionView handles HTMX requests correctly."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
self.client.login(username="moderator", password="modpass123")
# Create a pending submission
park = Park.objects.first()
if not park:
self.skipTest("No park available for testing")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Test change"},
reason="Integration test",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
)
# Make request with HTMX header
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Should return 200 OK
self.assertEqual(response.status_code, 200)
# Should have HX-Trigger header
self.assertIn("HX-Trigger", response)
# Parse HX-Trigger header
trigger_data = json.loads(response["HX-Trigger"])
self.assertIn("showToast", trigger_data)
self.assertEqual(trigger_data["showToast"]["type"], "success")
# Cleanup
submission.delete()
def test_fsm_transition_view_without_htmx_header(self):
"""Test that FSMTransitionView handles non-HTMX requests correctly."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
self.client.login(username="moderator", password="modpass123")
park = Park.objects.first()
if not park:
self.skipTest("No park available for testing")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Test change non-htmx"},
reason="Integration test non-htmx",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
)
# Make request WITHOUT HTMX header
response = self.client.post(url)
# Should return 200 OK with JSON response
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/json")
# Parse JSON response
data = response.json()
self.assertTrue(data["success"])
self.assertIn("message", data)
# Cleanup
submission.delete()
def test_fsm_transition_view_returns_correct_partial(self):
"""Test that FSMTransitionView returns correct partial template."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
self.client.login(username="moderator", password="modpass123")
park = Park.objects.first()
if not park:
self.skipTest("No park available for testing")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Test partial"},
reason="Partial test",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Response should contain HTML (partial template)
self.assertIn("text/html", response["Content-Type"])
# Cleanup
submission.delete()
def test_fsm_transition_view_adds_toast_trigger(self):
"""Test that FSMTransitionView adds correct HX-Trigger for toast."""
from apps.parks.models import Park
self.client.login(username="moderator", password="modpass123")
park = Park.objects.filter(status="OPERATING").first()
if not park:
self.skipTest("No operating park available for testing")
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "parks",
"model_name": "park",
"pk": park.pk,
"transition_name": "transition_to_closed_temp"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Parse HX-Trigger header
trigger_data = json.loads(response["HX-Trigger"])
# Verify toast structure
self.assertIn("showToast", trigger_data)
self.assertIn("message", trigger_data["showToast"])
self.assertIn("type", trigger_data["showToast"])
self.assertEqual(trigger_data["showToast"]["type"], "success")
# Reset park status for other tests
park.status = "OPERATING"
park.save()
def test_fsm_transition_view_handles_invalid_model(self):
"""Test that FSMTransitionView handles invalid model gracefully."""
self.client.login(username="moderator", password="modpass123")
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "nonexistent",
"model_name": "fakemodel",
"pk": 1,
"transition_name": "fake_transition"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Should return 404
self.assertEqual(response.status_code, 404)
# Should have error toast trigger
self.assertIn("HX-Trigger", response)
trigger_data = json.loads(response["HX-Trigger"])
self.assertEqual(trigger_data["showToast"]["type"], "error")
def test_fsm_transition_view_handles_invalid_transition(self):
"""Test that FSMTransitionView handles invalid transition name."""
from apps.parks.models import Park
self.client.login(username="moderator", password="modpass123")
park = Park.objects.first()
if not park:
self.skipTest("No park available for testing")
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "parks",
"model_name": "park",
"pk": park.pk,
"transition_name": "nonexistent_transition"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Should return 400 Bad Request
self.assertEqual(response.status_code, 400)
# Should have error toast trigger
self.assertIn("HX-Trigger", response)
trigger_data = json.loads(response["HX-Trigger"])
self.assertEqual(trigger_data["showToast"]["type"], "error")
def test_fsm_transition_view_validates_permissions(self):
"""Test that FSMTransitionView validates user permissions."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
# Login as regular user (not moderator)
self.client.login(username="testuser", password="testpass123")
park = Park.objects.first()
if not park:
self.skipTest("No park available for testing")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Permission test"},
reason="Permission test",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Should return 400 or 403 (permission denied)
self.assertIn(response.status_code, [400, 403])
# Verify submission was NOT changed
submission.refresh_from_db()
self.assertEqual(submission.status, "PENDING")
# Cleanup
submission.delete()
class TestFSMTransitionViewParkModel(TestCase):
"""Tests for FSMTransitionView with Park model."""
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
username="mod_park",
email="mod_park@example.com",
password="modpass123",
is_staff=True
)
def setUp(self):
self.client = Client()
self.client.login(username="mod_park", password="modpass123")
def test_park_close_temporarily_transition(self):
"""Test park close temporarily transition via view."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
self.skipTest("No operating park available")
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "parks",
"model_name": "park",
"pk": park.pk,
"transition_name": "transition_to_closed_temp"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify park status changed
park.refresh_from_db()
self.assertEqual(park.status, "CLOSED_TEMP")
# Reset for other tests
park.status = "OPERATING"
park.save()
def test_park_reopen_transition(self):
"""Test park reopen transition via view."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
self.skipTest("No park available")
# First close the park
park.status = "CLOSED_TEMP"
park.save()
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "parks",
"model_name": "park",
"pk": park.pk,
"transition_name": "transition_to_operating"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify park status changed
park.refresh_from_db()
self.assertEqual(park.status, "OPERATING")
def test_park_slug_based_transition(self):
"""Test FSM transition using slug instead of pk."""
from apps.parks.models import Park
park = Park.objects.filter(status="OPERATING").first()
if not park:
self.skipTest("No operating park available")
url = reverse(
"core:fsm_transition_by_slug",
kwargs={
"app_label": "parks",
"model_name": "park",
"slug": park.slug,
"transition_name": "transition_to_closed_temp"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify park status changed
park.refresh_from_db()
self.assertEqual(park.status, "CLOSED_TEMP")
# Reset for other tests
park.status = "OPERATING"
park.save()
class TestFSMTransitionViewRideModel(TestCase):
"""Tests for FSMTransitionView with Ride model."""
@classmethod
def setUpTestData(cls):
cls.moderator = User.objects.create_user(
username="mod_ride",
email="mod_ride@example.com",
password="modpass123",
is_staff=True
)
def setUp(self):
self.client = Client()
self.client.login(username="mod_ride", password="modpass123")
def test_ride_close_temporarily_transition(self):
"""Test ride close temporarily transition via view."""
from apps.rides.models import Ride
ride = Ride.objects.filter(status="OPERATING").first()
if not ride:
self.skipTest("No operating ride available")
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "rides",
"model_name": "ride",
"pk": ride.pk,
"transition_name": "transition_to_closed_temp"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify ride status changed
ride.refresh_from_db()
self.assertEqual(ride.status, "CLOSED_TEMP")
# Reset for other tests
ride.status = "OPERATING"
ride.save()
def test_ride_mark_sbno_transition(self):
"""Test ride mark SBNO transition via view."""
from apps.rides.models import Ride
ride = Ride.objects.filter(status="OPERATING").first()
if not ride:
self.skipTest("No operating ride available")
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "rides",
"model_name": "ride",
"pk": ride.pk,
"transition_name": "transition_to_sbno"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify ride status changed
ride.refresh_from_db()
self.assertEqual(ride.status, "SBNO")
# Reset for other tests
ride.status = "OPERATING"
ride.save()
class TestFSMTransitionViewModerationModels(TestCase):
"""Tests for FSMTransitionView with moderation models."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
username="submitter",
email="submitter@example.com",
password="testpass123"
)
cls.moderator = User.objects.create_user(
username="mod_moderation",
email="mod_moderation@example.com",
password="modpass123",
is_staff=True
)
def setUp(self):
self.client = Client()
self.client.login(username="mod_moderation", password="modpass123")
def test_edit_submission_approve_transition(self):
"""Test EditSubmission approve transition."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
park = Park.objects.first()
if not park:
self.skipTest("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Approve test"},
reason="Approve test",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify submission status changed
submission.refresh_from_db()
self.assertEqual(submission.status, "APPROVED")
# Cleanup
submission.delete()
def test_edit_submission_reject_transition(self):
"""Test EditSubmission reject transition."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
park = Park.objects.first()
if not park:
self.skipTest("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Reject test"},
reason="Reject test",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_rejected"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify submission status changed
submission.refresh_from_db()
self.assertEqual(submission.status, "REJECTED")
# Cleanup
submission.delete()
def test_edit_submission_escalate_transition(self):
"""Test EditSubmission escalate transition."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
park = Park.objects.first()
if not park:
self.skipTest("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "Escalate test"},
reason="Escalate test",
status="PENDING"
)
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_escalated"
}
)
response = self.client.post(
url,
HTTP_HX_REQUEST="true"
)
self.assertEqual(response.status_code, 200)
# Verify submission status changed
submission.refresh_from_db()
self.assertEqual(submission.status, "ESCALATED")
# Cleanup
submission.delete()
class TestFSMTransitionViewStateLog(TestCase):
"""Tests that FSM transitions create proper StateLog entries."""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(
username="submitter_log",
email="submitter_log@example.com",
password="testpass123"
)
cls.moderator = User.objects.create_user(
username="mod_log",
email="mod_log@example.com",
password="modpass123",
is_staff=True
)
def setUp(self):
self.client = Client()
self.client.login(username="mod_log", password="modpass123")
def test_transition_creates_state_log(self):
"""Test that FSM transition creates a StateLog entry."""
from apps.moderation.models import EditSubmission
from apps.parks.models import Park
from django_fsm_log.models import StateLog
park = Park.objects.first()
if not park:
self.skipTest("No park available")
content_type = ContentType.objects.get_for_model(Park)
submission = EditSubmission.objects.create(
user=self.user,
content_type=content_type,
object_id=park.pk,
submission_type="EDIT",
changes={"description": "StateLog test"},
reason="StateLog test",
status="PENDING"
)
# Count existing StateLog entries
initial_log_count = StateLog.objects.filter(
content_type=ContentType.objects.get_for_model(EditSubmission),
object_id=submission.pk
).count()
url = reverse(
"core:fsm_transition",
kwargs={
"app_label": "moderation",
"model_name": "editsubmission",
"pk": submission.pk,
"transition_name": "transition_to_approved"
}
)
self.client.post(
url,
HTTP_HX_REQUEST="true"
)
# Check that a new StateLog entry was created
new_log_count = StateLog.objects.filter(
content_type=ContentType.objects.get_for_model(EditSubmission),
object_id=submission.pk
).count()
self.assertEqual(new_log_count, initial_log_count + 1)
# Verify the StateLog entry details
latest_log = StateLog.objects.filter(
content_type=ContentType.objects.get_for_model(EditSubmission),
object_id=submission.pk
).latest('timestamp')
self.assertEqual(latest_log.state, "APPROVED")
self.assertEqual(latest_log.by, self.moderator)
# Cleanup
submission.delete()