mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 03:11:08 -05:00
- 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.
572 lines
16 KiB
Python
572 lines
16 KiB
Python
"""
|
|
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
|
|
}
|
|
)
|