| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378 |
- 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"
|