"""Tests for scheduled task admin API endpoints.""" from __future__ import annotations from typing import TYPE_CHECKING, Any import pytest from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware from starlette.routing import Mount, Route from starlette.testclient import TestClient if TYPE_CHECKING: from starlette.requests import Request from starlette.responses import Response from turnstone.console.server import ( admin_create_schedule, admin_delete_schedule, admin_get_schedule, admin_list_schedule_runs, admin_list_schedules, admin_update_schedule, ) from turnstone.core.auth import AuthResult from turnstone.core.storage._sqlite import SQLiteBackend class _InjectAuthMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Any) -> Response: request.state.auth_result = AuthResult( user_id="test-admin", scopes=frozenset({"approve"}), token_source="config", permissions=frozenset({"admin.schedules"}), ) return await call_next(request) @pytest.fixture def storage(tmp_path): """Fresh SQLite backend for each test.""" return SQLiteBackend(str(tmp_path / "test.db")) @pytest.fixture def client(storage): """TestClient with storage and auth bypassed.""" app = Starlette( routes=[ Mount( "/v1", routes=[ Route("/api/admin/schedules", admin_list_schedules), Route("/api/admin/schedules", admin_create_schedule, methods=["POST"]), Route("/api/admin/schedules/{task_id}", admin_get_schedule), Route( "/api/admin/schedules/{task_id}", admin_update_schedule, methods=["PUT"], ), Route( "/api/admin/schedules/{task_id}", admin_delete_schedule, methods=["DELETE"], ), Route( "/api/admin/schedules/{task_id}/runs", admin_list_schedule_runs, ), ], ), ], middleware=[Middleware(_InjectAuthMiddleware)], ) app.state.auth_storage = storage return TestClient(app) def _cron_payload(**overrides): """Build default cron schedule creation payload.""" defaults = { "name": "Daily report", "description": "Generate the summary", "schedule_type": "cron", "cron_expr": "0 9 * * *", "target_mode": "auto", "model": "gpt-5", "initial_message": "Generate the daily report", } defaults.update(overrides) return defaults def _at_payload(**overrides): """Build default at-time schedule creation payload.""" defaults = { "name": "One-shot task", "description": "Run once", "schedule_type": "at", "at_time": "2099-01-01T00:00:00+00:00", "target_mode": "auto", "model": "gpt-5", "initial_message": "Do the thing", } defaults.update(overrides) return defaults class TestScheduleAPI: """Tests for the 6 admin schedule endpoints.""" def test_list_empty(self, client): resp = client.get("/v1/api/admin/schedules") assert resp.status_code == 200 data = resp.json() assert data["schedules"] == [] def test_create_cron(self, client): resp = client.post("/v1/api/admin/schedules", json=_cron_payload()) assert resp.status_code == 200 task = resp.json() assert task["name"] == "Daily report" assert task["schedule_type"] == "cron" assert task["cron_expr"] == "0 9 * * *" assert task["enabled"] is True assert "task_id" in task assert "created" in task assert "next_run" in task assert task["next_run"] != "" def test_create_at(self, client): resp = client.post("/v1/api/admin/schedules", json=_at_payload()) assert resp.status_code == 200 task = resp.json() assert task["schedule_type"] == "at" assert task["at_time"] == "2099-01-01T00:00:00+00:00" assert task["next_run"] == "2099-01-01T00:00:00+00:00" def test_create_missing_name(self, client): payload = _cron_payload() del payload["name"] resp = client.post("/v1/api/admin/schedules", json=payload) assert resp.status_code == 400 assert "name" in resp.json()["error"].lower() def test_create_invalid_cron(self, client): resp = client.post( "/v1/api/admin/schedules", json=_cron_payload(cron_expr="not a cron"), ) assert resp.status_code == 400 assert "cron" in resp.json()["error"].lower() def test_create_naive_at_time(self, client): """Naive timestamps (no timezone) should be rejected.""" resp = client.post( "/v1/api/admin/schedules", json=_at_payload(at_time="2099-01-01T00:00:00"), ) assert resp.status_code == 400 assert "timezone" in resp.json()["error"].lower() def test_create_past_at_time(self, client): resp = client.post( "/v1/api/admin/schedules", json=_at_payload(at_time="2000-01-01T00:00:00+00:00"), ) assert resp.status_code == 400 assert "future" in resp.json()["error"].lower() def test_get_schedule(self, client): create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload()) task_id = create_resp.json()["task_id"] resp = client.get(f"/v1/api/admin/schedules/{task_id}") assert resp.status_code == 200 assert resp.json()["task_id"] == task_id assert resp.json()["name"] == "Daily report" def test_get_nonexistent(self, client): resp = client.get("/v1/api/admin/schedules/nonexistent_id") assert resp.status_code == 404 def test_update_schedule(self, client): create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload()) task_id = create_resp.json()["task_id"] resp = client.put( f"/v1/api/admin/schedules/{task_id}", json={"name": "Weekly report"}, ) assert resp.status_code == 200 assert resp.json()["name"] == "Weekly report" # Verify via GET get_resp = client.get(f"/v1/api/admin/schedules/{task_id}") assert get_resp.json()["name"] == "Weekly report" def test_update_nonexistent(self, client): resp = client.put( "/v1/api/admin/schedules/nonexistent_id", json={"name": "Nope"}, ) assert resp.status_code == 404 def test_delete_schedule(self, client): create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload()) task_id = create_resp.json()["task_id"] resp = client.delete(f"/v1/api/admin/schedules/{task_id}") assert resp.status_code == 200 assert resp.json()["status"] == "ok" # Verify gone get_resp = client.get(f"/v1/api/admin/schedules/{task_id}") assert get_resp.status_code == 404 def test_delete_nonexistent(self, client): resp = client.delete("/v1/api/admin/schedules/nonexistent_id") assert resp.status_code == 404 def test_list_runs_empty(self, client): create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload()) task_id = create_resp.json()["task_id"] resp = client.get(f"/v1/api/admin/schedules/{task_id}/runs") assert resp.status_code == 200 assert resp.json()["runs"] == [] def test_list_runs_nonexistent(self, client): resp = client.get("/v1/api/admin/schedules/nonexistent_id/runs") assert resp.status_code == 404 def test_create_specific_node_target(self, client): payload = _cron_payload(target_mode="node-custom-001") resp = client.post("/v1/api/admin/schedules", json=payload) assert resp.status_code == 200 data = resp.json() assert data["target_mode"] == "node-custom-001" def test_list_runs_with_data(self, client, storage): create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload()) task_id = create_resp.json()["task_id"] # Record runs directly in storage storage.record_task_run( run_id="run_001", task_id=task_id, node_id="node-1", ws_id="ws_abc", correlation_id="corr_001", started="2025-06-01T09:00:00", status="dispatched", error="", ) storage.record_task_run( run_id="run_002", task_id=task_id, node_id="node-2", ws_id="", correlation_id="corr_002", started="2025-06-01T09:01:00", status="failed", error="No reachable nodes", ) resp = client.get(f"/v1/api/admin/schedules/{task_id}/runs") assert resp.status_code == 200 runs = resp.json()["runs"] assert len(runs) == 2 # Most recent first assert runs[0]["run_id"] == "run_002" assert runs[0]["status"] == "failed" assert runs[1]["run_id"] == "run_001" def test_list_runs_invalid_limit(self, client): create_resp = client.post("/v1/api/admin/schedules", json=_cron_payload()) task_id = create_resp.json()["task_id"] # Invalid limit should not crash — falls back to 50 resp = client.get(f"/v1/api/admin/schedules/{task_id}/runs?limit=abc") assert resp.status_code == 200 assert resp.json()["runs"] == []