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