|
@@ -1,378 +0,0 @@
|
|
|
-import pytest
|
|
|
|
|
-import requests
|
|
|
|
|
-import time
|
|
|
|
|
-import os
|
|
|
|
|
-from datetime import datetime, timezone, timedelta
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@pytest.mark.e2e
|
|
|
|
|
-class TestAuthentication:
|
|
|
|
|
- def test_invalid_api_key_returns_401(self, api_base_url):
|
|
|
|
|
- """Invalid API key should return 401 Unauthorized."""
|
|
|
|
|
- headers = {"Authorization": "Bearer invalid-fake-key-123"}
|
|
|
|
|
- response = requests.get(f"{api_base_url}/schedules/recurring", headers=headers)
|
|
|
|
|
- assert response.status_code == 401
|
|
|
|
|
-
|
|
|
|
|
- def test_no_auth_header_returns_403(self, api_base_url):
|
|
|
|
|
- """Missing auth header should return 403."""
|
|
|
|
|
- response = requests.get(f"{api_base_url}/schedules/recurring")
|
|
|
|
|
- assert response.status_code == 403
|
|
|
|
|
-
|
|
|
|
|
- def test_api_key_not_in_response(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
|
|
|
|
|
- """API key should never be returned in responses."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
-
|
|
|
|
|
- # Create schedule
|
|
|
|
|
- payload = {
|
|
|
|
|
- "agent_id": valid_letta_agent_id,
|
|
|
|
|
- "cron": "*/5 * * * *",
|
|
|
|
|
- "message": "Test message",
|
|
|
|
|
- "role": "user"
|
|
|
|
|
- }
|
|
|
|
|
- response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
|
|
|
|
|
-
|
|
|
|
|
- # API key should not be in response
|
|
|
|
|
- data = response.json()
|
|
|
|
|
- assert "api_key" not in data
|
|
|
|
|
-
|
|
|
|
|
- # Clean up
|
|
|
|
|
- schedule_id = data["id"]
|
|
|
|
|
- requests.delete(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@pytest.mark.e2e
|
|
|
|
|
-class TestRecurringScheduleCRUD:
|
|
|
|
|
- def test_create_recurring_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
|
|
|
|
|
- """Should successfully create a recurring schedule."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
- payload = {
|
|
|
|
|
- "agent_id": valid_letta_agent_id,
|
|
|
|
|
- "cron": "0 9 * * *",
|
|
|
|
|
- "message": "Daily morning message",
|
|
|
|
|
- "role": "user"
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
|
|
|
|
|
- assert response.status_code == 201
|
|
|
|
|
-
|
|
|
|
|
- data = response.json()
|
|
|
|
|
- assert "id" in data
|
|
|
|
|
- assert data["cron"] == "0 9 * * *"
|
|
|
|
|
- assert data["message"] == "Daily morning message"
|
|
|
|
|
-
|
|
|
|
|
- # Clean up
|
|
|
|
|
- requests.delete(f"{api_base_url}/schedules/recurring/{data['id']}", headers=headers)
|
|
|
|
|
-
|
|
|
|
|
- def test_list_recurring_schedules(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
|
|
|
|
|
- """List should only return schedules for authenticated user."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
-
|
|
|
|
|
- # Create a schedule
|
|
|
|
|
- payload = {
|
|
|
|
|
- "agent_id": valid_letta_agent_id,
|
|
|
|
|
- "cron": "*/10 * * * *",
|
|
|
|
|
- "message": "Test",
|
|
|
|
|
- "role": "user"
|
|
|
|
|
- }
|
|
|
|
|
- create_response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
|
|
|
|
|
- schedule_id = create_response.json()["id"]
|
|
|
|
|
-
|
|
|
|
|
- # List schedules
|
|
|
|
|
- response = requests.get(f"{api_base_url}/schedules/recurring", headers=headers)
|
|
|
|
|
- assert response.status_code == 200
|
|
|
|
|
-
|
|
|
|
|
- schedules = response.json()
|
|
|
|
|
- assert isinstance(schedules, list)
|
|
|
|
|
-
|
|
|
|
|
- # Find our schedule
|
|
|
|
|
- our_schedule = next((s for s in schedules if s["id"] == schedule_id), None)
|
|
|
|
|
- assert our_schedule is not None
|
|
|
|
|
- assert our_schedule["cron"] == "*/10 * * * *"
|
|
|
|
|
-
|
|
|
|
|
- # Clean up
|
|
|
|
|
- requests.delete(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
|
|
|
|
|
-
|
|
|
|
|
- def test_get_recurring_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
|
|
|
|
|
- """Get should return specific schedule."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
-
|
|
|
|
|
- # Create schedule
|
|
|
|
|
- payload = {
|
|
|
|
|
- "agent_id": valid_letta_agent_id,
|
|
|
|
|
- "cron": "0 12 * * *",
|
|
|
|
|
- "message": "Noon message",
|
|
|
|
|
- "role": "user"
|
|
|
|
|
- }
|
|
|
|
|
- create_response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
|
|
|
|
|
- schedule_id = create_response.json()["id"]
|
|
|
|
|
-
|
|
|
|
|
- # Get schedule
|
|
|
|
|
- response = requests.get(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
|
|
|
|
|
- assert response.status_code == 200
|
|
|
|
|
-
|
|
|
|
|
- data = response.json()
|
|
|
|
|
- assert data["id"] == schedule_id
|
|
|
|
|
- assert data["cron"] == "0 12 * * *"
|
|
|
|
|
-
|
|
|
|
|
- # Clean up
|
|
|
|
|
- requests.delete(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
|
|
|
|
|
-
|
|
|
|
|
- def test_delete_recurring_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
|
|
|
|
|
- """Delete should remove schedule."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
-
|
|
|
|
|
- # Create schedule
|
|
|
|
|
- payload = {
|
|
|
|
|
- "agent_id": valid_letta_agent_id,
|
|
|
|
|
- "cron": "0 0 * * *",
|
|
|
|
|
- "message": "To be deleted",
|
|
|
|
|
- "role": "user"
|
|
|
|
|
- }
|
|
|
|
|
- create_response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
|
|
|
|
|
- schedule_id = create_response.json()["id"]
|
|
|
|
|
-
|
|
|
|
|
- # Delete schedule
|
|
|
|
|
- delete_response = requests.delete(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
|
|
|
|
|
- assert delete_response.status_code == 200
|
|
|
|
|
-
|
|
|
|
|
- # Verify it's gone
|
|
|
|
|
- get_response = requests.get(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
|
|
|
|
|
- assert get_response.status_code == 404
|
|
|
|
|
-
|
|
|
|
|
- def test_delete_nonexistent_returns_404(self, api_base_url, valid_letta_api_key):
|
|
|
|
|
- """Deleting non-existent schedule should return 404."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
- response = requests.delete(f"{api_base_url}/schedules/recurring/fake-uuid-123", headers=headers)
|
|
|
|
|
- assert response.status_code == 404
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@pytest.mark.e2e
|
|
|
|
|
-class TestOneTimeScheduleCRUD:
|
|
|
|
|
- def test_create_onetime_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
|
|
|
|
|
- """Should successfully create a one-time schedule."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
- future_time = datetime.now(timezone.utc) + timedelta(hours=1)
|
|
|
|
|
-
|
|
|
|
|
- payload = {
|
|
|
|
|
- "agent_id": valid_letta_agent_id,
|
|
|
|
|
- "execute_at": future_time.isoformat(),
|
|
|
|
|
- "message": "Future message",
|
|
|
|
|
- "role": "user"
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
|
|
|
|
|
- assert response.status_code == 201
|
|
|
|
|
-
|
|
|
|
|
- data = response.json()
|
|
|
|
|
- assert "id" in data
|
|
|
|
|
- assert data["execute_at"] == future_time.isoformat()
|
|
|
|
|
-
|
|
|
|
|
- # Clean up
|
|
|
|
|
- requests.delete(f"{api_base_url}/schedules/one-time/{data['id']}", headers=headers)
|
|
|
|
|
-
|
|
|
|
|
- def test_list_onetime_schedules(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
|
|
|
|
|
- """List should only return user's schedules."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
- future_time = datetime.now(timezone.utc) + timedelta(hours=2)
|
|
|
|
|
-
|
|
|
|
|
- payload = {
|
|
|
|
|
- "agent_id": valid_letta_agent_id,
|
|
|
|
|
- "execute_at": future_time.isoformat(),
|
|
|
|
|
- "message": "Test",
|
|
|
|
|
- "role": "user"
|
|
|
|
|
- }
|
|
|
|
|
- create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
|
|
|
|
|
- schedule_id = create_response.json()["id"]
|
|
|
|
|
-
|
|
|
|
|
- # List schedules
|
|
|
|
|
- response = requests.get(f"{api_base_url}/schedules/one-time", headers=headers)
|
|
|
|
|
- assert response.status_code == 200
|
|
|
|
|
-
|
|
|
|
|
- schedules = response.json()
|
|
|
|
|
- our_schedule = next((s for s in schedules if s["id"] == schedule_id), None)
|
|
|
|
|
- assert our_schedule is not None
|
|
|
|
|
-
|
|
|
|
|
- # Clean up
|
|
|
|
|
- requests.delete(f"{api_base_url}/schedules/one-time/{schedule_id}", headers=headers)
|
|
|
|
|
-
|
|
|
|
|
- def test_delete_onetime_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
|
|
|
|
|
- """Delete should remove one-time schedule."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
- future_time = datetime.now(timezone.utc) + timedelta(hours=3)
|
|
|
|
|
-
|
|
|
|
|
- payload = {
|
|
|
|
|
- "agent_id": valid_letta_agent_id,
|
|
|
|
|
- "execute_at": future_time.isoformat(),
|
|
|
|
|
- "message": "To be deleted",
|
|
|
|
|
- "role": "user"
|
|
|
|
|
- }
|
|
|
|
|
- create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
|
|
|
|
|
- schedule_id = create_response.json()["id"]
|
|
|
|
|
-
|
|
|
|
|
- # Delete
|
|
|
|
|
- delete_response = requests.delete(f"{api_base_url}/schedules/one-time/{schedule_id}", headers=headers)
|
|
|
|
|
- assert delete_response.status_code == 200
|
|
|
|
|
-
|
|
|
|
|
- # Verify gone
|
|
|
|
|
- get_response = requests.get(f"{api_base_url}/schedules/one-time/{schedule_id}", headers=headers)
|
|
|
|
|
- assert get_response.status_code == 404
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@pytest.mark.e2e
|
|
|
|
|
-class TestResults:
|
|
|
|
|
- def test_list_results(self, api_base_url, valid_letta_api_key):
|
|
|
|
|
- """Should list execution results."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
- response = requests.get(f"{api_base_url}/results", headers=headers)
|
|
|
|
|
- assert response.status_code == 200
|
|
|
|
|
- assert isinstance(response.json(), list)
|
|
|
|
|
-
|
|
|
|
|
- def test_get_result_nonexistent_returns_404(self, api_base_url, valid_letta_api_key):
|
|
|
|
|
- """Getting non-existent result should return 404."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
- response = requests.get(f"{api_base_url}/results/fake-uuid-123", headers=headers)
|
|
|
|
|
- assert response.status_code == 404
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@pytest.mark.e2e
|
|
|
|
|
-@pytest.mark.slow
|
|
|
|
|
-class TestExecution:
|
|
|
|
|
- def test_past_onetime_executes_immediately(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
|
|
|
|
|
- """One-time schedule in the past should execute within 1 minute."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
- past_time = datetime.now(timezone.utc) - timedelta(seconds=30)
|
|
|
|
|
-
|
|
|
|
|
- payload = {
|
|
|
|
|
- "agent_id": valid_letta_agent_id,
|
|
|
|
|
- "execute_at": past_time.isoformat(),
|
|
|
|
|
- "message": "Should execute immediately",
|
|
|
|
|
- "role": "user"
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
|
|
|
|
|
- schedule_id = create_response.json()["id"]
|
|
|
|
|
-
|
|
|
|
|
- # Wait up to 90 seconds for execution
|
|
|
|
|
- max_wait = 90
|
|
|
|
|
- for i in range(max_wait):
|
|
|
|
|
- time.sleep(1)
|
|
|
|
|
-
|
|
|
|
|
- # Check if result exists
|
|
|
|
|
- result_response = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
|
|
|
|
|
- if result_response.status_code == 200:
|
|
|
|
|
- result = result_response.json()
|
|
|
|
|
- assert "run_id" in result
|
|
|
|
|
- assert result["schedule_type"] == "one-time"
|
|
|
|
|
- print(f"✓ Executed in {i+1} seconds, run_id: {result['run_id']}")
|
|
|
|
|
- return
|
|
|
|
|
-
|
|
|
|
|
- pytest.fail(f"Schedule did not execute within {max_wait} seconds")
|
|
|
|
|
-
|
|
|
|
|
- def test_onetime_schedule_deleted_after_execution(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
|
|
|
|
|
- """One-time schedule should be deleted from filesystem after execution."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
- past_time = datetime.now(timezone.utc) - timedelta(seconds=30)
|
|
|
|
|
-
|
|
|
|
|
- payload = {
|
|
|
|
|
- "agent_id": valid_letta_agent_id,
|
|
|
|
|
- "execute_at": past_time.isoformat(),
|
|
|
|
|
- "message": "Should be deleted after execution",
|
|
|
|
|
- "role": "user"
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
|
|
|
|
|
- schedule_id = create_response.json()["id"]
|
|
|
|
|
-
|
|
|
|
|
- # Wait for execution
|
|
|
|
|
- for _ in range(90):
|
|
|
|
|
- time.sleep(1)
|
|
|
|
|
- result_response = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
|
|
|
|
|
- if result_response.status_code == 200:
|
|
|
|
|
- break
|
|
|
|
|
-
|
|
|
|
|
- # Schedule should be deleted
|
|
|
|
|
- schedule_response = requests.get(f"{api_base_url}/schedules/one-time/{schedule_id}", headers=headers)
|
|
|
|
|
- assert schedule_response.status_code == 404
|
|
|
|
|
-
|
|
|
|
|
- def test_result_persists_after_schedule_deletion(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
|
|
|
|
|
- """Execution result should persist even after schedule is deleted."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
- past_time = datetime.now(timezone.utc) - timedelta(seconds=30)
|
|
|
|
|
-
|
|
|
|
|
- payload = {
|
|
|
|
|
- "agent_id": valid_letta_agent_id,
|
|
|
|
|
- "execute_at": past_time.isoformat(),
|
|
|
|
|
- "message": "Test result persistence",
|
|
|
|
|
- "role": "user"
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
|
|
|
|
|
- schedule_id = create_response.json()["id"]
|
|
|
|
|
-
|
|
|
|
|
- # Wait for execution
|
|
|
|
|
- for _ in range(90):
|
|
|
|
|
- time.sleep(1)
|
|
|
|
|
- result_response = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
|
|
|
|
|
- if result_response.status_code == 200:
|
|
|
|
|
- result = result_response.json()
|
|
|
|
|
- break
|
|
|
|
|
-
|
|
|
|
|
- # Result should still exist
|
|
|
|
|
- final_result = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
|
|
|
|
|
- assert final_result.status_code == 200
|
|
|
|
|
- assert "run_id" in final_result.json()
|
|
|
|
|
-
|
|
|
|
|
- def test_no_duplicate_execution(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
|
|
|
|
|
- """One-time schedule should execute exactly once, even if multiple executors spawn."""
|
|
|
|
|
- headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
- past_time = datetime.now(timezone.utc) - timedelta(seconds=30)
|
|
|
|
|
-
|
|
|
|
|
- payload = {
|
|
|
|
|
- "agent_id": valid_letta_agent_id,
|
|
|
|
|
- "execute_at": past_time.isoformat(),
|
|
|
|
|
- "message": "Should only execute once",
|
|
|
|
|
- "role": "user"
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
|
|
|
|
|
- schedule_id = create_response.json()["id"]
|
|
|
|
|
-
|
|
|
|
|
- # Wait for execution
|
|
|
|
|
- time.sleep(90)
|
|
|
|
|
-
|
|
|
|
|
- # Check result
|
|
|
|
|
- result_response = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
|
|
|
|
|
- assert result_response.status_code == 200
|
|
|
|
|
-
|
|
|
|
|
- # There should be exactly one result file, indicating one execution
|
|
|
|
|
- # (We can't directly verify this without filesystem access, but we can check result exists)
|
|
|
|
|
- result = result_response.json()
|
|
|
|
|
- assert result["schedule_id"] == schedule_id
|
|
|
|
|
- assert "run_id" in result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@pytest.mark.e2e
|
|
|
|
|
-class TestAuthorization:
|
|
|
|
|
- def test_user_isolation_list(self, api_base_url, valid_letta_api_key):
|
|
|
|
|
- """Users should only see their own schedules in list."""
|
|
|
|
|
- # This test requires a second valid API key
|
|
|
|
|
- # Skip if not available
|
|
|
|
|
- second_key = os.getenv("LETTA_API_KEY_2")
|
|
|
|
|
- if not second_key:
|
|
|
|
|
- pytest.skip("LETTA_API_KEY_2 not set for multi-user testing")
|
|
|
|
|
-
|
|
|
|
|
- headers1 = {"Authorization": f"Bearer {valid_letta_api_key}"}
|
|
|
|
|
- headers2 = {"Authorization": f"Bearer {second_key}"}
|
|
|
|
|
-
|
|
|
|
|
- # User 1's list should not contain User 2's schedules
|
|
|
|
|
- response1 = requests.get(f"{api_base_url}/schedules/recurring", headers=headers1)
|
|
|
|
|
- response2 = requests.get(f"{api_base_url}/schedules/recurring", headers=headers2)
|
|
|
|
|
-
|
|
|
|
|
- schedules1 = response1.json()
|
|
|
|
|
- schedules2 = response2.json()
|
|
|
|
|
-
|
|
|
|
|
- # Lists should be independent (no overlap)
|
|
|
|
|
- ids1 = {s["id"] for s in schedules1}
|
|
|
|
|
- ids2 = {s["id"] for s in schedules2}
|
|
|
|
|
-
|
|
|
|
|
- assert ids1.isdisjoint(ids2), "Users should not see each other's schedules"
|
|
|