test_api_e2e.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. import pytest
  2. import requests
  3. import time
  4. import os
  5. from datetime import datetime, timezone, timedelta
  6. @pytest.mark.e2e
  7. class TestAuthentication:
  8. def test_invalid_api_key_returns_401(self, api_base_url):
  9. """Invalid API key should return 401 Unauthorized."""
  10. headers = {"Authorization": "Bearer invalid-fake-key-123"}
  11. response = requests.get(f"{api_base_url}/schedules/recurring", headers=headers)
  12. assert response.status_code == 401
  13. def test_no_auth_header_returns_403(self, api_base_url):
  14. """Missing auth header should return 403."""
  15. response = requests.get(f"{api_base_url}/schedules/recurring")
  16. assert response.status_code == 403
  17. def test_api_key_not_in_response(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  18. """API key should never be returned in responses."""
  19. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  20. # Create schedule
  21. payload = {
  22. "agent_id": valid_letta_agent_id,
  23. "cron": "*/5 * * * *",
  24. "message": "Test message",
  25. "role": "user"
  26. }
  27. response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
  28. # API key should not be in response
  29. data = response.json()
  30. assert "api_key" not in data
  31. # Clean up
  32. schedule_id = data["id"]
  33. requests.delete(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
  34. @pytest.mark.e2e
  35. class TestRecurringScheduleCRUD:
  36. def test_create_recurring_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  37. """Should successfully create a recurring schedule."""
  38. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  39. payload = {
  40. "agent_id": valid_letta_agent_id,
  41. "cron": "0 9 * * *",
  42. "message": "Daily morning message",
  43. "role": "user"
  44. }
  45. response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
  46. assert response.status_code == 201
  47. data = response.json()
  48. assert "id" in data
  49. assert data["cron"] == "0 9 * * *"
  50. assert data["message"] == "Daily morning message"
  51. # Clean up
  52. requests.delete(f"{api_base_url}/schedules/recurring/{data['id']}", headers=headers)
  53. def test_list_recurring_schedules(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  54. """List should only return schedules for authenticated user."""
  55. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  56. # Create a schedule
  57. payload = {
  58. "agent_id": valid_letta_agent_id,
  59. "cron": "*/10 * * * *",
  60. "message": "Test",
  61. "role": "user"
  62. }
  63. create_response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
  64. schedule_id = create_response.json()["id"]
  65. # List schedules
  66. response = requests.get(f"{api_base_url}/schedules/recurring", headers=headers)
  67. assert response.status_code == 200
  68. schedules = response.json()
  69. assert isinstance(schedules, list)
  70. # Find our schedule
  71. our_schedule = next((s for s in schedules if s["id"] == schedule_id), None)
  72. assert our_schedule is not None
  73. assert our_schedule["cron"] == "*/10 * * * *"
  74. # Clean up
  75. requests.delete(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
  76. def test_get_recurring_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  77. """Get should return specific schedule."""
  78. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  79. # Create schedule
  80. payload = {
  81. "agent_id": valid_letta_agent_id,
  82. "cron": "0 12 * * *",
  83. "message": "Noon message",
  84. "role": "user"
  85. }
  86. create_response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
  87. schedule_id = create_response.json()["id"]
  88. # Get schedule
  89. response = requests.get(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
  90. assert response.status_code == 200
  91. data = response.json()
  92. assert data["id"] == schedule_id
  93. assert data["cron"] == "0 12 * * *"
  94. # Clean up
  95. requests.delete(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
  96. def test_delete_recurring_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  97. """Delete should remove schedule."""
  98. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  99. # Create schedule
  100. payload = {
  101. "agent_id": valid_letta_agent_id,
  102. "cron": "0 0 * * *",
  103. "message": "To be deleted",
  104. "role": "user"
  105. }
  106. create_response = requests.post(f"{api_base_url}/schedules/recurring", json=payload, headers=headers)
  107. schedule_id = create_response.json()["id"]
  108. # Delete schedule
  109. delete_response = requests.delete(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
  110. assert delete_response.status_code == 200
  111. # Verify it's gone
  112. get_response = requests.get(f"{api_base_url}/schedules/recurring/{schedule_id}", headers=headers)
  113. assert get_response.status_code == 404
  114. def test_delete_nonexistent_returns_404(self, api_base_url, valid_letta_api_key):
  115. """Deleting non-existent schedule should return 404."""
  116. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  117. response = requests.delete(f"{api_base_url}/schedules/recurring/fake-uuid-123", headers=headers)
  118. assert response.status_code == 404
  119. @pytest.mark.e2e
  120. class TestOneTimeScheduleCRUD:
  121. def test_create_onetime_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  122. """Should successfully create a one-time schedule."""
  123. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  124. future_time = datetime.now(timezone.utc) + timedelta(hours=1)
  125. payload = {
  126. "agent_id": valid_letta_agent_id,
  127. "execute_at": future_time.isoformat(),
  128. "message": "Future message",
  129. "role": "user"
  130. }
  131. response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  132. assert response.status_code == 201
  133. data = response.json()
  134. assert "id" in data
  135. assert data["execute_at"] == future_time.isoformat()
  136. # Clean up
  137. requests.delete(f"{api_base_url}/schedules/one-time/{data['id']}", headers=headers)
  138. def test_list_onetime_schedules(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  139. """List should only return user's schedules."""
  140. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  141. future_time = datetime.now(timezone.utc) + timedelta(hours=2)
  142. payload = {
  143. "agent_id": valid_letta_agent_id,
  144. "execute_at": future_time.isoformat(),
  145. "message": "Test",
  146. "role": "user"
  147. }
  148. create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  149. schedule_id = create_response.json()["id"]
  150. # List schedules
  151. response = requests.get(f"{api_base_url}/schedules/one-time", headers=headers)
  152. assert response.status_code == 200
  153. schedules = response.json()
  154. our_schedule = next((s for s in schedules if s["id"] == schedule_id), None)
  155. assert our_schedule is not None
  156. # Clean up
  157. requests.delete(f"{api_base_url}/schedules/one-time/{schedule_id}", headers=headers)
  158. def test_delete_onetime_schedule(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  159. """Delete should remove one-time schedule."""
  160. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  161. future_time = datetime.now(timezone.utc) + timedelta(hours=3)
  162. payload = {
  163. "agent_id": valid_letta_agent_id,
  164. "execute_at": future_time.isoformat(),
  165. "message": "To be deleted",
  166. "role": "user"
  167. }
  168. create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  169. schedule_id = create_response.json()["id"]
  170. # Delete
  171. delete_response = requests.delete(f"{api_base_url}/schedules/one-time/{schedule_id}", headers=headers)
  172. assert delete_response.status_code == 200
  173. # Verify gone
  174. get_response = requests.get(f"{api_base_url}/schedules/one-time/{schedule_id}", headers=headers)
  175. assert get_response.status_code == 404
  176. @pytest.mark.e2e
  177. class TestResults:
  178. def test_list_results(self, api_base_url, valid_letta_api_key):
  179. """Should list execution results."""
  180. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  181. response = requests.get(f"{api_base_url}/results", headers=headers)
  182. assert response.status_code == 200
  183. assert isinstance(response.json(), list)
  184. def test_get_result_nonexistent_returns_404(self, api_base_url, valid_letta_api_key):
  185. """Getting non-existent result should return 404."""
  186. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  187. response = requests.get(f"{api_base_url}/results/fake-uuid-123", headers=headers)
  188. assert response.status_code == 404
  189. @pytest.mark.e2e
  190. @pytest.mark.slow
  191. class TestExecution:
  192. def test_past_onetime_executes_immediately(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  193. """One-time schedule in the past should execute within 1 minute."""
  194. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  195. past_time = datetime.now(timezone.utc) - timedelta(seconds=30)
  196. payload = {
  197. "agent_id": valid_letta_agent_id,
  198. "execute_at": past_time.isoformat(),
  199. "message": "Should execute immediately",
  200. "role": "user"
  201. }
  202. create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  203. schedule_id = create_response.json()["id"]
  204. # Wait up to 90 seconds for execution
  205. max_wait = 90
  206. for i in range(max_wait):
  207. time.sleep(1)
  208. # Check if result exists
  209. result_response = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
  210. if result_response.status_code == 200:
  211. result = result_response.json()
  212. assert "run_id" in result
  213. assert result["schedule_type"] == "one-time"
  214. print(f"✓ Executed in {i+1} seconds, run_id: {result['run_id']}")
  215. return
  216. pytest.fail(f"Schedule did not execute within {max_wait} seconds")
  217. def test_onetime_schedule_deleted_after_execution(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  218. """One-time schedule should be deleted from filesystem after execution."""
  219. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  220. past_time = datetime.now(timezone.utc) - timedelta(seconds=30)
  221. payload = {
  222. "agent_id": valid_letta_agent_id,
  223. "execute_at": past_time.isoformat(),
  224. "message": "Should be deleted after execution",
  225. "role": "user"
  226. }
  227. create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  228. schedule_id = create_response.json()["id"]
  229. # Wait for execution
  230. for _ in range(90):
  231. time.sleep(1)
  232. result_response = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
  233. if result_response.status_code == 200:
  234. break
  235. # Schedule should be deleted
  236. schedule_response = requests.get(f"{api_base_url}/schedules/one-time/{schedule_id}", headers=headers)
  237. assert schedule_response.status_code == 404
  238. def test_result_persists_after_schedule_deletion(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  239. """Execution result should persist even after schedule is deleted."""
  240. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  241. past_time = datetime.now(timezone.utc) - timedelta(seconds=30)
  242. payload = {
  243. "agent_id": valid_letta_agent_id,
  244. "execute_at": past_time.isoformat(),
  245. "message": "Test result persistence",
  246. "role": "user"
  247. }
  248. create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  249. schedule_id = create_response.json()["id"]
  250. # Wait for execution
  251. for _ in range(90):
  252. time.sleep(1)
  253. result_response = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
  254. if result_response.status_code == 200:
  255. result = result_response.json()
  256. break
  257. # Result should still exist
  258. final_result = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
  259. assert final_result.status_code == 200
  260. assert "run_id" in final_result.json()
  261. def test_no_duplicate_execution(self, api_base_url, valid_letta_api_key, valid_letta_agent_id):
  262. """One-time schedule should execute exactly once, even if multiple executors spawn."""
  263. headers = {"Authorization": f"Bearer {valid_letta_api_key}"}
  264. past_time = datetime.now(timezone.utc) - timedelta(seconds=30)
  265. payload = {
  266. "agent_id": valid_letta_agent_id,
  267. "execute_at": past_time.isoformat(),
  268. "message": "Should only execute once",
  269. "role": "user"
  270. }
  271. create_response = requests.post(f"{api_base_url}/schedules/one-time", json=payload, headers=headers)
  272. schedule_id = create_response.json()["id"]
  273. # Wait for execution
  274. time.sleep(90)
  275. # Check result
  276. result_response = requests.get(f"{api_base_url}/results/{schedule_id}", headers=headers)
  277. assert result_response.status_code == 200
  278. # There should be exactly one result file, indicating one execution
  279. # (We can't directly verify this without filesystem access, but we can check result exists)
  280. result = result_response.json()
  281. assert result["schedule_id"] == schedule_id
  282. assert "run_id" in result
  283. @pytest.mark.e2e
  284. class TestAuthorization:
  285. def test_user_isolation_list(self, api_base_url, valid_letta_api_key):
  286. """Users should only see their own schedules in list."""
  287. # This test requires a second valid API key
  288. # Skip if not available
  289. second_key = os.getenv("LETTA_API_KEY_2")
  290. if not second_key:
  291. pytest.skip("LETTA_API_KEY_2 not set for multi-user testing")
  292. headers1 = {"Authorization": f"Bearer {valid_letta_api_key}"}
  293. headers2 = {"Authorization": f"Bearer {second_key}"}
  294. # User 1's list should not contain User 2's schedules
  295. response1 = requests.get(f"{api_base_url}/schedules/recurring", headers=headers1)
  296. response2 = requests.get(f"{api_base_url}/schedules/recurring", headers=headers2)
  297. schedules1 = response1.json()
  298. schedules2 = response2.json()
  299. # Lists should be independent (no overlap)
  300. ids1 = {s["id"] for s in schedules1}
  301. ids2 = {s["id"] for s in schedules2}
  302. assert ids1.isdisjoint(ids2), "Users should not see each other's schedules"