re-uploading work
This commit is contained in:
537
tests/test_e2e_basic.py
Normal file
537
tests/test_e2e_basic.py
Normal file
@@ -0,0 +1,537 @@
|
||||
#!/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": "trigger.payload.body.message", "!=": None}]
|
||||
},
|
||||
"action_params": {"message": "{{ trigger.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"])
|
||||
Reference in New Issue
Block a user