Files
attune/tests/test_e2e_basic.py

538 lines
19 KiB
Python

#!/usr/bin/env python3
"""
End-to-End Integration Tests - Basic Scenarios
Tests basic automation flows across all 5 Attune services:
- API, Executor, Worker, Sensor, Notifier
These tests require all services to be running.
Run with: pytest tests/test_e2e_basic.py -v -s
"""
import json
import os
import time
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
import pytest
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# ============================================================================
# Configuration
# ============================================================================
API_BASE_URL = os.getenv("ATTUNE_API_URL", "http://localhost:8080")
TEST_TIMEOUT = int(os.getenv("TEST_TIMEOUT", "60")) # seconds
POLL_INTERVAL = 0.5 # seconds
# ============================================================================
# Test Fixtures & Helpers
# ============================================================================
class AttuneClient:
"""Client for interacting with Attune API"""
def __init__(self, base_url: str):
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
self.token: Optional[str] = None
# Configure retry strategy
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
def login(self, login: str = "admin@attune.local", password: str = "AdminPass123!"):
"""Authenticate and get JWT token"""
try:
response = self.session.post(
f"{self.base_url}/auth/login",
json={"login": login, "password": password},
)
response.raise_for_status()
data = response.json()
self.token = data["data"]["access_token"]
self.session.headers.update({"Authorization": f"Bearer {self.token}"})
return self.token
except requests.exceptions.HTTPError as e:
# If login fails with 401/404, try to register the user first
if e.response.status_code in [401, 404]:
self.register(login, password)
# Retry login after registration
response = self.session.post(
f"{self.base_url}/auth/login",
json={"login": login, "password": password},
)
response.raise_for_status()
data = response.json()
self.token = data["data"]["access_token"]
self.session.headers.update({"Authorization": f"Bearer {self.token}"})
return self.token
raise
def register(self, login: str, password: str, display_name: str = "Test Admin"):
"""Register a new user"""
response = self.session.post(
f"{self.base_url}/auth/register",
json={
"login": login,
"password": password,
"display_name": display_name,
},
)
response.raise_for_status()
return response.json()
def _request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
"""Make authenticated request"""
if not self.token and path != "/auth/login":
self.login()
url = f"{self.base_url}{path}"
response = self.session.request(method, url, **kwargs)
response.raise_for_status()
return response.json()
def get(self, path: str, **kwargs) -> Dict[str, Any]:
return self._request("GET", path, **kwargs)
def post(self, path: str, **kwargs) -> Dict[str, Any]:
return self._request("POST", path, **kwargs)
def put(self, path: str, **kwargs) -> Dict[str, Any]:
return self._request("PUT", path, **kwargs)
def delete(self, path: str, **kwargs) -> Dict[str, Any]:
return self._request("DELETE", path, **kwargs)
# ========================================================================
# Pack Management
# ========================================================================
def register_pack(self, path: str, skip_tests: bool = True) -> Dict[str, Any]:
"""Register a pack from local directory"""
return self.post(
"/api/v1/packs/register",
json={"path": path, "force": True, "skip_tests": skip_tests},
)
def get_pack(self, pack_ref: str) -> Dict[str, Any]:
"""Get pack by ref"""
return self.get(f"/api/v1/packs/{pack_ref}")
# ========================================================================
# Actions
# ========================================================================
def create_action(self, action_data: Dict[str, Any]) -> Dict[str, Any]:
"""Create an action"""
return self.post("/api/v1/actions", json=action_data)
def get_action(self, action_ref: str) -> Dict[str, Any]:
"""Get action by ref"""
return self.get(f"/api/v1/actions/{action_ref}")
# ========================================================================
# Triggers
# ========================================================================
def create_trigger(self, trigger_data: Dict[str, Any]) -> Dict[str, Any]:
"""Create a trigger"""
return self.post("/api/v1/triggers", json=trigger_data)
def get_trigger(self, trigger_ref: str) -> Dict[str, Any]:
"""Get trigger by ref"""
return self.get(f"/api/v1/triggers/{trigger_ref}")
# ========================================================================
# Sensors
# ========================================================================
def create_sensor(self, sensor_data: Dict[str, Any]) -> Dict[str, Any]:
"""Create a sensor"""
return self.post("/api/v1/sensors", json=sensor_data)
def get_sensor(self, sensor_ref: str) -> Dict[str, Any]:
"""Get sensor by ref"""
return self.get(f"/api/v1/sensors/{sensor_ref}")
# ========================================================================
# Rules
# ========================================================================
def create_rule(self, rule_data: Dict[str, Any]) -> Dict[str, Any]:
"""Create a rule"""
return self.post("/api/v1/rules", json=rule_data)
def get_rule(self, rule_ref: str) -> Dict[str, Any]:
"""Get rule by ref"""
return self.get(f"/api/v1/rules/{rule_ref}")
# ========================================================================
# Events
# ========================================================================
def get_events(
self, limit: int = 10, trigger_ref: Optional[str] = None
) -> Dict[str, Any]:
"""Get recent events"""
params = {"limit": limit}
if trigger_ref:
params["trigger_ref"] = trigger_ref
return self.get("/api/v1/events", params=params)
def get_event(self, event_id: int) -> Dict[str, Any]:
"""Get event by ID"""
return self.get(f"/api/v1/events/{event_id}")
# ========================================================================
# Executions
# ========================================================================
def get_executions(
self, limit: int = 10, action_ref: Optional[str] = None
) -> Dict[str, Any]:
"""Get recent executions"""
params = {"limit": limit}
if action_ref:
params["action_ref"] = action_ref
return self.get("/api/v1/executions", params=params)
def get_execution(self, execution_id: int) -> Dict[str, Any]:
"""Get execution by ID"""
return self.get(f"/api/v1/executions/{execution_id}")
def wait_for_execution_status(
self, execution_id: int, target_status: str, timeout: int = TEST_TIMEOUT
) -> Dict[str, Any]:
"""Poll execution until it reaches target status"""
start_time = time.time()
while time.time() - start_time < timeout:
exec_data = self.get_execution(execution_id)
current_status = exec_data["data"]["status"]
if current_status == target_status:
return exec_data
if current_status in ["failed", "timeout", "canceled"]:
raise RuntimeError(
f"Execution {execution_id} reached terminal status '{current_status}' "
f"while waiting for '{target_status}'"
)
time.sleep(POLL_INTERVAL)
raise TimeoutError(
f"Execution {execution_id} did not reach status '{target_status}' "
f"within {timeout} seconds"
)
@pytest.fixture(scope="session")
def client():
"""Create and authenticate API client"""
c = AttuneClient(API_BASE_URL)
c.login()
return c
@pytest.fixture(scope="session")
def test_pack(client):
"""Register test pack"""
# Try multiple possible paths (depending on where pytest is run from)
possible_paths = [
"fixtures/packs/test_pack", # Running from tests/
"tests/fixtures/packs/test_pack", # Running from project root
os.path.join(
os.path.dirname(__file__), "fixtures/packs/test_pack"
), # Relative to this file
]
pack_path = None
for path in possible_paths:
abs_path = os.path.abspath(path)
if os.path.exists(abs_path):
pack_path = abs_path
break
if not pack_path:
pytest.skip(f"Test pack not found. Tried: {possible_paths}")
# Register the pack (handle if already exists)
try:
result = client.register_pack(pack_path)
pack_ref = result["data"]["ref"]
except requests.exceptions.HTTPError as e:
# If pack already exists (409 Conflict), get the pack ref from the error or default
if e.response.status_code == 409:
# Pack already exists, use default name
pack_ref = "test_pack"
else:
raise
yield pack_ref
# Cleanup: Delete pack after tests
# Note: This will cascade delete actions, rules, etc.
# client.delete(f"/api/v1/packs/{pack_ref}")
@pytest.fixture
def unique_ref():
"""Generate unique ref for test resources"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
return f"test_{timestamp}"
# ============================================================================
# Test Cases
# ============================================================================
class TestBasicAutomation:
"""Test basic automation flows"""
def test_api_health(self, client):
"""Test API health endpoint"""
response = client.get("/health")
assert response["status"] == "ok"
def test_authentication(self):
"""Test login and token generation"""
c = AttuneClient(API_BASE_URL)
token = c.login(login="test@attune.local", password="TestPass123!")
assert token is not None
assert len(token) > 20 # JWT tokens are long
def test_pack_registration(self, client, test_pack):
"""Test pack can be registered"""
pack_data = client.get_pack(test_pack)
assert pack_data["data"]["ref"] == test_pack
assert pack_data["data"]["label"] == "E2E Test Pack"
assert pack_data["data"]["version"] == "1.0.0"
def test_create_simple_action(self, client, test_pack, unique_ref):
"""Test creating a simple echo action
Note: Action creation requires specific schema:
- pack_ref (not 'pack')
- entrypoint (not 'entry_point')
- param_schema (JSON Schema, not 'parameters')
- No 'runner_type' or 'enabled' fields in CreateActionRequest
"""
action_ref = f"{test_pack}.{unique_ref}"
action_data = {
"ref": action_ref,
"pack_ref": test_pack,
"label": "Test Echo Action",
"description": "Simple echo action for testing",
"entrypoint": "actions/echo.py",
"param_schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Message to echo",
}
},
"required": ["message"],
},
}
result = client.create_action(action_data)
assert result["data"]["ref"] == action_ref
# Verify we can retrieve it
retrieved = client.get_action(action_ref)
assert retrieved["data"]["ref"] == action_ref
def test_create_automation_rule(self, client, test_pack, unique_ref):
"""
Test creating a complete automation rule with trigger and action.
This test creates:
1. A webhook trigger (simpler than timer triggers)
2. An echo action
3. A rule linking the trigger to the action
Note: Actually firing the trigger requires the sensor service to be running.
This test only validates that the rule can be created successfully.
"""
# Step 1: Create a webhook trigger
trigger_ref = f"{test_pack}.{unique_ref}_webhook"
trigger_data = {
"ref": trigger_ref,
"pack_ref": test_pack,
"label": "Test Webhook Trigger",
"description": "Webhook trigger for E2E testing",
"enabled": True,
"param_schema": {
"type": "object",
"properties": {
"url_path": {
"type": "string",
"description": "URL path for webhook",
},
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE"],
"default": "POST",
},
},
"required": ["url_path"],
},
"out_schema": {
"type": "object",
"properties": {
"headers": {"type": "object"},
"body": {"type": "object"},
"query": {"type": "object"},
},
},
}
trigger_result = client.create_trigger(trigger_data)
assert trigger_result["data"]["ref"] == trigger_ref
# Step 2: Create an echo action
action_ref = f"{test_pack}.{unique_ref}_echo"
action_data = {
"ref": action_ref,
"pack_ref": test_pack,
"label": "Test Echo Action",
"description": "Echo action for E2E testing",
"entrypoint": "actions/echo.py",
"param_schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Message to echo",
}
},
"required": ["message"],
},
}
action_result = client.create_action(action_data)
assert action_result["data"]["ref"] == action_ref
# Step 3: Create a rule linking trigger to action
rule_ref = f"{test_pack}.{unique_ref}_rule"
rule_data = {
"ref": rule_ref,
"pack_ref": test_pack,
"label": "Test Webhook to Echo Rule",
"description": "Rule that echoes webhook payloads",
"action_ref": action_ref,
"trigger_ref": trigger_ref,
"conditions": {
"and": [{"var": "event.payload.body.message", "!=": None}]
},
"action_params": {"message": "{{ event.payload.body.message }}"},
"enabled": True,
}
rule_result = client.create_rule(rule_data)
assert rule_result["data"]["ref"] == rule_ref
assert rule_result["data"]["action_ref"] == action_ref
assert rule_result["data"]["trigger_ref"] == trigger_ref
assert rule_result["data"]["enabled"] is True
# Verify we can retrieve the rule
retrieved_rule = client.get_rule(rule_ref)
assert retrieved_rule["data"]["ref"] == rule_ref
assert retrieved_rule["data"]["action_ref"] == action_ref
assert retrieved_rule["data"]["trigger_ref"] == trigger_ref
class TestManualExecution:
"""Test manual action execution (without sensor/trigger flow)
This tests the ability to execute an action directly via the API
without requiring a trigger or rule.
"""
def test_execute_action_directly(self, client, test_pack, unique_ref):
"""
Test executing an action directly via API
This tests: API → Executor → Worker flow
Uses the POST /api/v1/executions/execute endpoint to directly
execute an action with parameters.
"""
# Create an echo action first
action_ref = f"{test_pack}.{unique_ref}_manual_echo"
action_data = {
"ref": action_ref,
"pack_ref": test_pack,
"label": "Manual Execution Test Action",
"description": "Echo action for manual execution testing",
"entrypoint": "actions/echo.py",
"param_schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Message to echo",
}
},
"required": ["message"],
},
}
action_result = client.create_action(action_data)
assert action_result["data"]["ref"] == action_ref
# Execute the action manually
execution_request = {
"action_ref": action_ref,
"parameters": {"message": "Hello from manual execution!"},
}
execution_result = client.post(
"/api/v1/executions/execute", json=execution_request
)
# Verify execution was created
assert "data" in execution_result
execution = execution_result["data"]
assert execution["action_ref"] == action_ref
assert execution["status"].lower() in [
"requested",
"scheduling",
"scheduled",
"running",
]
assert execution["config"]["message"] == "Hello from manual execution!"
execution_id = execution["id"]
# Verify we can retrieve the execution
retrieved = client.get_execution(execution_id)
assert retrieved["data"]["id"] == execution_id
assert retrieved["data"]["action_ref"] == action_ref
# ============================================================================
# Main
# ============================================================================
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])