538 lines
19 KiB
Python
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"])
|