""" Pytest Configuration and Shared Fixtures for E2E Tests This module provides shared fixtures and configuration for all end-to-end tests. """ import os import subprocess import sys import time from typing import Generator import pytest # Add project root to path for imports project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if project_root not in sys.path: sys.path.insert(0, project_root) from helpers import AttuneClient, create_test_pack, unique_ref # ============================================================================ # Session-scoped Fixtures # ============================================================================ @pytest.fixture(scope="session") def api_base_url() -> str: """Get API base URL from environment""" return os.getenv("ATTUNE_API_URL", "http://localhost:8080") @pytest.fixture(scope="session") def test_timeout() -> int: """Get test timeout from environment""" return int(os.getenv("TEST_TIMEOUT", "60")) @pytest.fixture(scope="session") def test_user_credentials() -> dict: """Get test user credentials""" return { "login": os.getenv("TEST_USER_LOGIN", "test@attune.local"), "password": os.getenv("TEST_USER_PASSWORD", "TestPass123!"), "display_name": "E2E Test User", } # ============================================================================ # Function-scoped Fixtures # ============================================================================ @pytest.fixture def client(api_base_url: str, test_timeout: int) -> Generator[AttuneClient, None, None]: """ Create authenticated Attune API client This fixture creates a new client for each test function and automatically logs in. The client is cleaned up after the test completes. """ client = AttuneClient(base_url=api_base_url, timeout=test_timeout) # Auto-login with test credentials try: client.login() except Exception as e: pytest.fail(f"Failed to authenticate client: {e}") yield client # Cleanup: logout client.logout() @pytest.fixture def unique_user_client( api_base_url: str, test_timeout: int ) -> Generator[AttuneClient, None, None]: """ Create client with unique test user This fixture creates a new user for each test, ensuring complete isolation between tests. Useful for multi-tenancy tests. """ client = AttuneClient(base_url=api_base_url, timeout=test_timeout, auto_login=False) # Generate unique credentials timestamp = int(time.time()) login = f"test_{timestamp}_{unique_ref()}@attune.local" password = "TestPass123!" # Register and login try: client.register( login=login, password=password, display_name=f"Test User {timestamp}" ) client.login(login=login, password=password) except Exception as e: pytest.fail(f"Failed to create unique user: {e}") yield client # Cleanup client.logout() @pytest.fixture def test_pack(client: AttuneClient) -> dict: """ Create or get test pack This fixture ensures the test pack is available for tests. """ try: pack = create_test_pack(client, pack_dir="tests/fixtures/packs/test_pack") return pack except Exception as e: pytest.fail(f"Failed to create test pack: {e}") @pytest.fixture def pack_ref(test_pack: dict) -> str: """Get pack reference from test pack""" return test_pack["ref"] @pytest.fixture(scope="function") def clean_test_data(request): """ Clean test data after each test to prevent interference with next test This fixture runs after each test function and cleans up test-related data to ensure isolation between tests. Usage: Add 'clean_test_data' to test function parameters to enable cleanup """ # Run the test first yield # Only clean if running E2E tests (not unit tests) if "e2e" not in request.node.nodeid: return db_url = os.getenv( "DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/attune_e2e" ) try: # Clean up test data but preserve core pack and test user # Only clean events, enforcements, and executions from recent test runs subprocess.run( [ "psql", db_url, "-c", """ -- Delete recent test-created events and enforcements DELETE FROM attune.event WHERE created > NOW() - INTERVAL '5 minutes'; DELETE FROM attune.enforcement WHERE created > NOW() - INTERVAL '5 minutes'; DELETE FROM attune.execution WHERE created > NOW() - INTERVAL '5 minutes'; DELETE FROM attune.inquiry WHERE created > NOW() - INTERVAL '5 minutes'; """, ], capture_output=True, text=True, timeout=10, ) except Exception as e: # Don't fail tests if cleanup fails print(f"Warning: Test data cleanup failed: {e}") @pytest.fixture(scope="session", autouse=True) def setup_database(): """ Ensure database is properly set up before running tests This runs once per test session to verify runtimes are seeded. """ db_url = os.getenv( "DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/attune_e2e" ) # Check if runtimes exist result = subprocess.run( [ "psql", db_url, "-t", "-c", "SELECT COUNT(*) FROM attune.runtime WHERE pack_ref = 'core';", ], capture_output=True, text=True, timeout=10, ) runtime_count = int(result.stdout.strip()) if result.returncode == 0 else 0 if runtime_count == 0: print("\n⚠ No runtimes found, seeding default runtimes...") # Seed runtimes scripts_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" ) seed_file = os.path.join(scripts_dir, "seed_runtimes.sql") if os.path.exists(seed_file): subprocess.run( ["psql", db_url, "-f", seed_file], capture_output=True, text=True, timeout=30, ) print("āœ“ Runtimes seeded successfully") else: print(f"āœ— Seed file not found: {seed_file}") yield # ============================================================================ # Helper Fixtures # ============================================================================ @pytest.fixture def wait_time() -> dict: """ Standard wait times for various operations Returns a dict with common wait times to keep tests consistent. """ return { "quick": 2, # Quick operations (API calls) "short": 5, # Short operations (simple executions) "medium": 15, # Medium operations (workflows) "long": 30, # Long operations (multi-step workflows) "extended": 60, # Extended operations (slow timers) } # ============================================================================ # Pytest Hooks # ============================================================================ def pytest_configure(config): """ Pytest configuration hook Called before test collection starts. """ # Add custom markers config.addinivalue_line("markers", "tier1: Tier 1 core tests") config.addinivalue_line("markers", "tier2: Tier 2 orchestration tests") config.addinivalue_line("markers", "tier3: Tier 3 advanced tests") def pytest_collection_modifyitems(config, items): """ Modify test collection Called after test collection to modify or re-order tests. """ # Sort tests by marker priority (tier1 -> tier2 -> tier3) tier_order = {"tier1": 0, "tier2": 1, "tier3": 2, None: 3} def get_tier_priority(item): for marker in item.iter_markers(): if marker.name in tier_order: return tier_order[marker.name] return tier_order[None] items.sort(key=get_tier_priority) def pytest_report_header(config): """ Add custom header to test report Returns list of strings to display at top of test run. """ api_url = os.getenv("ATTUNE_API_URL", "http://localhost:8080") return [ f"Attune E2E Test Suite", f"API URL: {api_url}", f"Test Timeout: {os.getenv('TEST_TIMEOUT', '60')}s", ] def pytest_runtest_setup(item): """ Hook called before each test Can be used for test-specific setup or to skip tests based on conditions. """ # Check if API is reachable before running tests api_url = os.getenv("ATTUNE_API_URL", "http://localhost:8080") # Only check on first test if not hasattr(pytest_runtest_setup, "_api_checked"): import requests try: response = requests.get(f"{api_url}/health", timeout=5) if response.status_code != 200: pytest.exit(f"API health check failed: {response.status_code}") except requests.exceptions.RequestException as e: pytest.exit(f"Cannot reach Attune API at {api_url}: {e}") pytest_runtest_setup._api_checked = True def pytest_runtest_teardown(item, nextitem): """ Hook called after each test Can be used for cleanup or logging. """ pass # ============================================================================ # Cleanup Helpers # ============================================================================ @pytest.fixture(autouse=True) def cleanup_on_failure(request): """ Auto-cleanup fixture that captures test state on failure This fixture runs for every test and captures useful debug info if the test fails. """ yield # If test failed, capture additional debug info if request.node.rep_call.failed if hasattr(request.node, "rep_call") else False: print("\n=== Test Failed - Debug Info ===") print(f"Test: {request.node.name}") print(f"Location: {request.node.location}") # Add more debug info as needed @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): """ Hook to capture test results for use in fixtures This allows fixtures to check if test passed/failed. """ outcome = yield rep = outcome.get_result() setattr(item, f"rep_{rep.when}", rep)