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,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
}
)