mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 14:31:09 -05:00
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:
571
backend/tests/utils/fsm_test_helpers.py
Normal file
571
backend/tests/utils/fsm_test_helpers.py
Normal file
@@ -0,0 +1,571 @@
|
||||
"""
|
||||
FSM Test Helpers
|
||||
|
||||
Reusable utility functions for testing FSM transitions:
|
||||
- Factory functions for creating test objects in specific states
|
||||
- Assertion helpers for common FSM test scenarios
|
||||
- Playwright helpers for HTMX swap verification
|
||||
- Toast notification verification utilities
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
from django.db.models import Model
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import HttpResponse
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Factory Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def create_test_submission(
|
||||
status: str = "PENDING",
|
||||
user: Optional[User] = None,
|
||||
park: Optional[Model] = None,
|
||||
submission_type: str = "EDIT",
|
||||
changes: Optional[Dict[str, Any]] = None,
|
||||
reason: str = "Test submission",
|
||||
**kwargs
|
||||
) -> "EditSubmission":
|
||||
"""
|
||||
Create a test EditSubmission with the given status.
|
||||
|
||||
Args:
|
||||
status: The initial status (PENDING, APPROVED, REJECTED, ESCALATED)
|
||||
user: The submitting user (created if not provided)
|
||||
park: The park to edit (first park used if not provided)
|
||||
submission_type: EDIT or CREATE
|
||||
changes: The changes JSON (default: {"description": "Test change"})
|
||||
reason: The submission reason
|
||||
**kwargs: Additional fields to set on the submission
|
||||
|
||||
Returns:
|
||||
EditSubmission instance
|
||||
"""
|
||||
from apps.moderation.models import EditSubmission
|
||||
from apps.parks.models import Park
|
||||
|
||||
# Get or create user
|
||||
if user is None:
|
||||
user, _ = User.objects.get_or_create(
|
||||
username="test_submitter",
|
||||
defaults={"email": "test_submitter@example.com"}
|
||||
)
|
||||
user.set_password("testpass123")
|
||||
user.save()
|
||||
|
||||
# Get or create park
|
||||
if park is None:
|
||||
park = Park.objects.first()
|
||||
if not park:
|
||||
raise ValueError("No park available for testing")
|
||||
|
||||
# Default changes
|
||||
if changes is None:
|
||||
changes = {"description": "Test change"}
|
||||
|
||||
content_type = ContentType.objects.get_for_model(Park)
|
||||
|
||||
submission = EditSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=content_type,
|
||||
object_id=park.pk,
|
||||
submission_type=submission_type,
|
||||
changes=changes,
|
||||
reason=reason,
|
||||
status=status,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
return submission
|
||||
|
||||
|
||||
def create_test_park(
|
||||
status: str = "OPERATING",
|
||||
name: Optional[str] = None,
|
||||
slug: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> "Park":
|
||||
"""
|
||||
Create a test Park with the given status.
|
||||
|
||||
Args:
|
||||
status: The initial status (OPERATING, CLOSED_TEMP, CLOSED_PERM, DEMOLISHED, RELOCATED)
|
||||
name: Park name (auto-generated if not provided)
|
||||
slug: Park slug (auto-generated if not provided)
|
||||
**kwargs: Additional fields to set on the park
|
||||
|
||||
Returns:
|
||||
Park instance
|
||||
"""
|
||||
from tests.factories import ParkFactory
|
||||
|
||||
if name is None:
|
||||
import random
|
||||
name = f"Test Park {random.randint(1000, 9999)}"
|
||||
|
||||
if slug is None:
|
||||
from django.utils.text import slugify
|
||||
slug = slugify(name)
|
||||
|
||||
park = ParkFactory(
|
||||
name=name,
|
||||
slug=slug,
|
||||
status=status,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
return park
|
||||
|
||||
|
||||
def create_test_ride(
|
||||
status: str = "OPERATING",
|
||||
name: Optional[str] = None,
|
||||
slug: Optional[str] = None,
|
||||
park: Optional[Model] = None,
|
||||
**kwargs
|
||||
) -> "Ride":
|
||||
"""
|
||||
Create a test Ride with the given status.
|
||||
|
||||
Args:
|
||||
status: The initial status (OPERATING, CLOSED_TEMP, SBNO, CLOSING, CLOSED_PERM, DEMOLISHED, RELOCATED)
|
||||
name: Ride name (auto-generated if not provided)
|
||||
slug: Ride slug (auto-generated if not provided)
|
||||
park: The park this ride belongs to (created if not provided)
|
||||
**kwargs: Additional fields to set on the ride
|
||||
|
||||
Returns:
|
||||
Ride instance
|
||||
"""
|
||||
from tests.factories import RideFactory
|
||||
|
||||
if name is None:
|
||||
import random
|
||||
name = f"Test Ride {random.randint(1000, 9999)}"
|
||||
|
||||
if slug is None:
|
||||
from django.utils.text import slugify
|
||||
slug = slugify(name)
|
||||
|
||||
ride_kwargs = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"status": status,
|
||||
**kwargs
|
||||
}
|
||||
|
||||
if park is not None:
|
||||
ride_kwargs["park"] = park
|
||||
|
||||
ride = RideFactory(**ride_kwargs)
|
||||
|
||||
return ride
|
||||
|
||||
|
||||
def create_test_photo_submission(
|
||||
status: str = "PENDING",
|
||||
user: Optional[User] = None,
|
||||
park: Optional[Model] = None,
|
||||
**kwargs
|
||||
) -> "PhotoSubmission":
|
||||
"""
|
||||
Create a test PhotoSubmission with the given status.
|
||||
|
||||
Args:
|
||||
status: The initial status (PENDING, APPROVED, REJECTED, ESCALATED)
|
||||
user: The submitting user (created if not provided)
|
||||
park: The park for the photo (first park used if not provided)
|
||||
**kwargs: Additional fields to set on the submission
|
||||
|
||||
Returns:
|
||||
PhotoSubmission instance
|
||||
"""
|
||||
from apps.moderation.models import PhotoSubmission
|
||||
from apps.parks.models import Park
|
||||
|
||||
# Get or create user
|
||||
if user is None:
|
||||
user, _ = User.objects.get_or_create(
|
||||
username="test_photo_submitter",
|
||||
defaults={"email": "test_photo@example.com"}
|
||||
)
|
||||
user.set_password("testpass123")
|
||||
user.save()
|
||||
|
||||
# Get or create park
|
||||
if park is None:
|
||||
park = Park.objects.first()
|
||||
if not park:
|
||||
raise ValueError("No park available for testing")
|
||||
|
||||
content_type = ContentType.objects.get_for_model(Park)
|
||||
|
||||
# Get a photo if available
|
||||
try:
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
photo = CloudflareImage.objects.first()
|
||||
if not photo:
|
||||
raise ValueError("No CloudflareImage available for testing")
|
||||
except ImportError:
|
||||
raise ValueError("CloudflareImage not available")
|
||||
|
||||
submission = PhotoSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=content_type,
|
||||
object_id=park.pk,
|
||||
photo=photo,
|
||||
caption="Test photo submission",
|
||||
status=status,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
return submission
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Assertion Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def assert_status_changed(obj: Model, expected_status: str) -> None:
|
||||
"""
|
||||
Assert that an object's status has changed to the expected value.
|
||||
|
||||
Args:
|
||||
obj: The model instance to check
|
||||
expected_status: The expected status value
|
||||
|
||||
Raises:
|
||||
AssertionError: If status doesn't match expected
|
||||
"""
|
||||
obj.refresh_from_db()
|
||||
actual_status = getattr(obj, "status", None)
|
||||
assert actual_status == expected_status, (
|
||||
f"Expected status '{expected_status}', got '{actual_status}'"
|
||||
)
|
||||
|
||||
|
||||
def assert_state_log_created(
|
||||
obj: Model,
|
||||
transition_name: str,
|
||||
user: Optional[User] = None,
|
||||
expected_state: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Assert that a StateLog entry was created for a transition.
|
||||
|
||||
Args:
|
||||
obj: The model instance that was transitioned
|
||||
transition_name: The name of the transition (optional, for verification)
|
||||
user: The user who performed the transition (optional)
|
||||
expected_state: The expected final state in the log
|
||||
|
||||
Raises:
|
||||
AssertionError: If no matching StateLog entry found
|
||||
"""
|
||||
from django_fsm_log.models import StateLog
|
||||
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
|
||||
logs = StateLog.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=obj.pk
|
||||
).order_by('-timestamp')
|
||||
|
||||
assert logs.exists(), "No StateLog entries found for object"
|
||||
|
||||
latest_log = logs.first()
|
||||
|
||||
if expected_state is not None:
|
||||
assert latest_log.state == expected_state, (
|
||||
f"Expected state '{expected_state}' in log, got '{latest_log.state}'"
|
||||
)
|
||||
|
||||
if user is not None:
|
||||
assert latest_log.by == user, (
|
||||
f"Expected log by user '{user}', got '{latest_log.by}'"
|
||||
)
|
||||
|
||||
|
||||
def assert_toast_triggered(
|
||||
response: HttpResponse,
|
||||
message: Optional[str] = None,
|
||||
toast_type: str = "success"
|
||||
) -> None:
|
||||
"""
|
||||
Assert that the response contains an HX-Trigger header with toast data.
|
||||
|
||||
Args:
|
||||
response: The HTTP response to check
|
||||
message: Expected message substring (optional)
|
||||
toast_type: Expected toast type ('success', 'error', 'warning', 'info')
|
||||
|
||||
Raises:
|
||||
AssertionError: If toast trigger not found or doesn't match
|
||||
"""
|
||||
assert "HX-Trigger" in response, "Response missing HX-Trigger header"
|
||||
|
||||
trigger_data = json.loads(response["HX-Trigger"])
|
||||
assert "showToast" in trigger_data, "HX-Trigger missing showToast event"
|
||||
|
||||
toast_data = trigger_data["showToast"]
|
||||
assert toast_data.get("type") == toast_type, (
|
||||
f"Expected toast type '{toast_type}', got '{toast_data.get('type')}'"
|
||||
)
|
||||
|
||||
if message is not None:
|
||||
assert message in toast_data.get("message", ""), (
|
||||
f"Expected '{message}' in toast message, got '{toast_data.get('message')}'"
|
||||
)
|
||||
|
||||
|
||||
def assert_no_status_change(obj: Model, original_status: str) -> None:
|
||||
"""
|
||||
Assert that an object's status has NOT changed from the original.
|
||||
|
||||
Args:
|
||||
obj: The model instance to check
|
||||
original_status: The original status value
|
||||
|
||||
Raises:
|
||||
AssertionError: If status has changed
|
||||
"""
|
||||
obj.refresh_from_db()
|
||||
actual_status = getattr(obj, "status", None)
|
||||
assert actual_status == original_status, (
|
||||
f"Status should not have changed from '{original_status}', but is now '{actual_status}'"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Playwright Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def wait_for_htmx_swap(
|
||||
page,
|
||||
target_selector: str,
|
||||
timeout: int = 5000
|
||||
) -> None:
|
||||
"""
|
||||
Wait for an HTMX swap to complete on a target element.
|
||||
|
||||
Args:
|
||||
page: Playwright page object
|
||||
target_selector: CSS selector for the target element
|
||||
timeout: Maximum time to wait in milliseconds
|
||||
"""
|
||||
# Wait for the htmx:afterSwap event
|
||||
page.wait_for_function(
|
||||
f"""
|
||||
() => {{
|
||||
const el = document.querySelector('{target_selector}');
|
||||
return el && !el.classList.contains('htmx-request');
|
||||
}}
|
||||
""",
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
|
||||
def verify_transition_buttons_visible(
|
||||
page,
|
||||
transitions: List[str],
|
||||
container_selector: str = "[data-status-actions]"
|
||||
) -> Dict[str, bool]:
|
||||
"""
|
||||
Verify which transition buttons are visible on the page.
|
||||
|
||||
Args:
|
||||
page: Playwright page object
|
||||
transitions: List of transition names to check (e.g., ["Approve", "Reject"])
|
||||
container_selector: CSS selector for the container holding the buttons
|
||||
|
||||
Returns:
|
||||
Dict mapping transition name to visibility boolean
|
||||
"""
|
||||
container = page.locator(container_selector)
|
||||
results = {}
|
||||
|
||||
for transition in transitions:
|
||||
button = container.get_by_role("button", name=transition)
|
||||
results[transition] = button.is_visible()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_status_badge_text(page, badge_selector: str = "[data-status-badge]") -> str:
|
||||
"""
|
||||
Get the text content of the status badge.
|
||||
|
||||
Args:
|
||||
page: Playwright page object
|
||||
badge_selector: CSS selector for the status badge
|
||||
|
||||
Returns:
|
||||
The text content of the status badge
|
||||
"""
|
||||
badge = page.locator(badge_selector)
|
||||
return badge.text_content().strip() if badge.is_visible() else ""
|
||||
|
||||
|
||||
def get_status_badge_class(page, badge_selector: str = "[data-status-badge]") -> str:
|
||||
"""
|
||||
Get the class attribute of the status badge.
|
||||
|
||||
Args:
|
||||
page: Playwright page object
|
||||
badge_selector: CSS selector for the status badge
|
||||
|
||||
Returns:
|
||||
The class attribute of the status badge
|
||||
"""
|
||||
badge = page.locator(badge_selector)
|
||||
return badge.get_attribute("class") or ""
|
||||
|
||||
|
||||
def wait_for_toast(page, toast_selector: str = "[data-toast]", timeout: int = 5000):
|
||||
"""
|
||||
Wait for a toast notification to appear.
|
||||
|
||||
Args:
|
||||
page: Playwright page object
|
||||
toast_selector: CSS selector for the toast element
|
||||
timeout: Maximum time to wait in milliseconds
|
||||
|
||||
Returns:
|
||||
The toast element locator
|
||||
"""
|
||||
toast = page.locator(toast_selector)
|
||||
toast.wait_for(state="visible", timeout=timeout)
|
||||
return toast
|
||||
|
||||
|
||||
def wait_for_toast_dismiss(
|
||||
page,
|
||||
toast_selector: str = "[data-toast]",
|
||||
timeout: int = 10000
|
||||
) -> None:
|
||||
"""
|
||||
Wait for a toast notification to be dismissed.
|
||||
|
||||
Args:
|
||||
page: Playwright page object
|
||||
toast_selector: CSS selector for the toast element
|
||||
timeout: Maximum time to wait in milliseconds
|
||||
"""
|
||||
toast = page.locator(toast_selector)
|
||||
toast.wait_for(state="hidden", timeout=timeout)
|
||||
|
||||
|
||||
def click_and_confirm(page, button_locator, accept: bool = True) -> None:
|
||||
"""
|
||||
Click a button and handle the confirmation dialog.
|
||||
|
||||
Args:
|
||||
page: Playwright page object
|
||||
button_locator: The button locator to click
|
||||
accept: Whether to accept (True) or dismiss (False) the dialog
|
||||
"""
|
||||
def handle_dialog(dialog):
|
||||
if accept:
|
||||
dialog.accept()
|
||||
else:
|
||||
dialog.dismiss()
|
||||
|
||||
page.on("dialog", handle_dialog)
|
||||
button_locator.click()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Client Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def make_htmx_post(client, url: str, data: Optional[Dict] = None) -> HttpResponse:
|
||||
"""
|
||||
Make a POST request with HTMX headers.
|
||||
|
||||
Args:
|
||||
client: Django test client
|
||||
url: The URL to POST to
|
||||
data: Optional POST data
|
||||
|
||||
Returns:
|
||||
HttpResponse from the request
|
||||
"""
|
||||
return client.post(
|
||||
url,
|
||||
data=data or {},
|
||||
HTTP_HX_REQUEST="true"
|
||||
)
|
||||
|
||||
|
||||
def make_htmx_get(client, url: str) -> HttpResponse:
|
||||
"""
|
||||
Make a GET request with HTMX headers.
|
||||
|
||||
Args:
|
||||
client: Django test client
|
||||
url: The URL to GET
|
||||
|
||||
Returns:
|
||||
HttpResponse from the request
|
||||
"""
|
||||
return client.get(
|
||||
url,
|
||||
HTTP_HX_REQUEST="true"
|
||||
)
|
||||
|
||||
|
||||
def get_fsm_transition_url(
|
||||
app_label: str,
|
||||
model_name: str,
|
||||
pk: int,
|
||||
transition_name: str,
|
||||
use_slug: bool = False,
|
||||
slug: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Generate the URL for an FSM transition.
|
||||
|
||||
Args:
|
||||
app_label: The Django app label (e.g., 'moderation')
|
||||
model_name: The model name in lowercase (e.g., 'editsubmission')
|
||||
pk: The object's primary key
|
||||
transition_name: The name of the transition method
|
||||
use_slug: Whether to use slug-based URL
|
||||
slug: The object's slug (required if use_slug is True)
|
||||
|
||||
Returns:
|
||||
The transition URL string
|
||||
"""
|
||||
from django.urls import reverse
|
||||
|
||||
if use_slug:
|
||||
if slug is None:
|
||||
raise ValueError("slug is required when use_slug is True")
|
||||
return reverse(
|
||||
"core:fsm_transition_by_slug",
|
||||
kwargs={
|
||||
"app_label": app_label,
|
||||
"model_name": model_name,
|
||||
"slug": slug,
|
||||
"transition_name": transition_name
|
||||
}
|
||||
)
|
||||
else:
|
||||
return reverse(
|
||||
"core:fsm_transition",
|
||||
kwargs={
|
||||
"app_label": app_label,
|
||||
"model_name": model_name,
|
||||
"pk": pk,
|
||||
"transition_name": transition_name
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user