"""E2E API Coverage Tests — endpoints not covered by test_basic_api.py. Covers: 1. /api/v1/experts — 专家模板列表/详情 2. /api/v1/workflows — 工作流列表/详情 3. /api/v1/config — 配置同步 (version/all/skills/agents/workflows) 4. /api/v1/system/resources — 系统资源监控 5. /api/v1/evolution — 进化事件 (store 未配置时 503) 6. /api/v1/evolution-dashboard — 进化看板 (metrics/experiences/usage/pitfalls) 7. /api/v1/settings — 设置 (GET 各类配置;PUT 需 SYSTEM_CONFIG → 403) 8. /api/v1/auth — 认证 (login 无效凭据 401;whoami 无 bearer 401) 9. /api/v1/memory — 记忆 (retriever 未配置时 503) 每个端点至少 2 个测试:Happy path + Auth/Error path。 api_client fixture 已携带 X-API-Key(operator 角色),所以主要测试 happy path; 对需要更高权限的端点验证 403,对未配置后端的端点验证 503/空数据。 """ import os import subprocess import sys import time from typing import Generator import httpx import pytest import yaml pytestmark = pytest.mark.e2e_basic # --------------------------------------------------------------------------- # Local e2e_server fixture — overrides conftest's version for this module. # # The conftest has two bugs that prevent the server from starting: # 1. Uses `python -m agentkit.cli.main` — but main.py has no # `if __name__ == "__main__": app()` block, so the module imports # silently and exits without invoking the Typer app. # 2. Even with the correct entry point (`python -m agentkit`), the CLI's # `serve` command calls `Confirm.ask` for onboarding when # `has_llm_provider()` is False (mock provider has no api_key), # blocking forever in a subprocess with no TTY. # # This fixture bypasses the CLI entirely: it writes a minimal startup script # that imports `create_app` and runs uvicorn directly. The config path is # passed via the `AGENTKIT_CONFIG_PATH` env var that `create_app` reads. # --------------------------------------------------------------------------- @pytest.fixture(scope="session") def e2e_server(tmp_path_factory: pytest.TempPathFactory) -> Generator[str, None, None]: """Start a real AgentKit server for this module's tests.""" from tests.e2e.conftest import ( E2E_API_KEY, E2E_HOST, E2E_PORT, _build_mock_env, ) tmp_path = tmp_path_factory.mktemp("e2e_cov_server") config_dir = tmp_path / "config" config_dir.mkdir() config_file = config_dir / "agentkit.yaml" config_file.write_text( yaml.dump( { "server": { "host": E2E_HOST, "port": E2E_PORT, # server.api_key is what AuthMiddleware reads (NOT auth.api_keys). # Without it, the middleware runs in dev mode and all requests pass. "api_key": E2E_API_KEY, }, "llm": {"default_provider": "mock", "providers": {"mock": {"type": "mock"}}}, } ) ) # Minimal startup script: set config path, import create_app, run uvicorn. # This avoids the CLI's interactive onboarding prompt entirely. startup_script = tmp_path / "start_server.py" startup_script.write_text( "import os, uvicorn\n" "from agentkit.server.app import create_app\n" f'uvicorn.run(create_app, factory=True, host="{E2E_HOST}", ' f"port={E2E_PORT}, log_level='warning')\n" ) env = _build_mock_env(tmp_path) env["AGENTKIT_CONFIG_PATH"] = str(config_file) # Ensure PYTHONPATH includes the project root so `agentkit` is importable env["PYTHONPATH"] = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) proc = subprocess.Popen( [sys.executable, "-u", str(startup_script)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=str(tmp_path), ) base_url = f"http://{E2E_HOST}:{E2E_PORT}" deadline = time.monotonic() + 45 ready = False while time.monotonic() < deadline: try: resp = httpx.get(f"{base_url}/api/v1/health", timeout=2) if resp.status_code == 200: ready = True break except httpx.RequestError: # ConnectError (port not open) OR ReadTimeout (lifespan not ready) pass time.sleep(0.5) if not ready: proc.terminate() try: stdout, stderr = proc.communicate(timeout=5) except subprocess.TimeoutExpired: proc.kill() stdout, stderr = proc.communicate() pytest.fail( f"E2E server failed to start within 45s.\n" f"stdout: {stdout.decode()[:2000]}\n" f"stderr: {stderr.decode()[:2000]}" ) yield base_url proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() # ═══════════════════════════════════════════════════════════════════════════ # 1. Experts API — /api/v1/experts # ═══════════════════════════════════════════════════════════════════════════ class TestExpertsAPI: def test_list_experts(self, api_client: httpx.Client): resp = api_client.get("/api/v1/experts") assert resp.status_code == 200 data = resp.json() assert "experts" in data assert "total" in data assert isinstance(data["experts"], list) assert data["total"] == len(data["experts"]) def test_get_expert_not_found(self, api_client: httpx.Client): resp = api_client.get("/api/v1/experts/nonexistent_expert_xyz") assert resp.status_code == 404 def test_list_experts_no_api_key(self, e2e_server: str): """无 API key 时应被中间件拒绝(401)。""" client = httpx.Client(base_url=e2e_server, timeout=10) resp = client.get("/api/v1/experts") assert resp.status_code == 401 # ═══════════════════════════════════════════════════════════════════════════ # 2. Workflows API — /api/v1/workflows # ═══════════════════════════════════════════════════════════════════════════ class TestWorkflowsAPI: def test_list_workflows(self, api_client: httpx.Client): resp = api_client.get("/api/v1/workflows") assert resp.status_code == 200 data = resp.json() assert "workflows" in data assert "total" in data assert isinstance(data["workflows"], list) def test_get_workflow_not_found(self, api_client: httpx.Client): resp = api_client.get("/api/v1/workflows/nonexistent_wf_id") assert resp.status_code == 404 def test_create_workflow_invalid_body(self, api_client: httpx.Client): """缺少必填字段应返回 422。""" resp = api_client.post("/api/v1/workflows", json={}) assert resp.status_code == 422 # ═══════════════════════════════════════════════════════════════════════════ # 3. Config Sync API — /api/v1/config # ═══════════════════════════════════════════════════════════════════════════ class TestConfigSyncAPI: def test_get_config_version(self, api_client: httpx.Client): resp = api_client.get("/api/v1/config/version") assert resp.status_code == 200 data = resp.json() assert "version" in data assert "skill_count" in data assert "workflow_count" in data def test_get_all_configs(self, api_client: httpx.Client): resp = api_client.get("/api/v1/config/all") assert resp.status_code == 200 data = resp.json() assert "version" in data assert "skills" in data assert "workflows" in data assert "synced_at" in data def test_get_skill_configs(self, api_client: httpx.Client): resp = api_client.get("/api/v1/config/skills") assert resp.status_code == 200 data = resp.json() assert "skills" in data assert "count" in data def test_get_config_no_api_key(self, e2e_server: str): """config 端点需 CHAT 权限;无 API key 时中间件返回 401。""" client = httpx.Client(base_url=e2e_server, timeout=10) resp = client.get("/api/v1/config/version") assert resp.status_code == 401 # ═══════════════════════════════════════════════════════════════════════════ # 4. System Resources API — /api/v1/system/resources # ═══════════════════════════════════════════════════════════════════════════ class TestSystemAPI: def test_get_system_resources(self, api_client: httpx.Client): """端点明确不挂 SYSTEM_CONFIG(见路由注释),operator 可读。""" resp = api_client.get("/api/v1/system/resources") assert resp.status_code == 200 data = resp.json() assert "cpu" in data assert "memory" in data assert "disk" in data assert "timestamp" in data def test_get_system_resources_no_api_key(self, e2e_server: str): client = httpx.Client(base_url=e2e_server, timeout=10) resp = client.get("/api/v1/system/resources") assert resp.status_code == 401 # ═══════════════════════════════════════════════════════════════════════════ # 5. Evolution API — /api/v1/evolution # E2E 配置无 evolution 段,store 为 None → 503 # ═══════════════════════════════════════════════════════════════════════════ class TestEvolutionAPI: def test_list_evolution_events_store_unconfigured(self, api_client: httpx.Client): """E2E server 未配置 evolution store,应返回 503。""" resp = api_client.get("/api/v1/evolution/events") assert resp.status_code == 503 def test_list_ab_tests_store_unconfigured(self, api_client: httpx.Client): resp = api_client.get("/api/v1/evolution/ab-tests") assert resp.status_code == 503 def test_trigger_evolution_no_agent(self, api_client: httpx.Client): """trigger 在 store 未配置时也应返回 503(_get_evolution_store 抛 503)。""" resp = api_client.post( "/api/v1/evolution/trigger", json={"agent_name": "no_such_agent"}, ) assert resp.status_code == 503 # ═══════════════════════════════════════════════════════════════════════════ # 6. Evolution Dashboard API — /api/v1/evolution-dashboard # 使用自带 _verify_api_key;store 未配置时回退到内存空数据 → 200 # ═══════════════════════════════════════════════════════════════════════════ class TestEvolutionDashboardAPI: def test_get_metrics(self, api_client: httpx.Client): resp = api_client.get("/api/v1/evolution-dashboard/metrics") assert resp.status_code == 200 data = resp.json() # 默认空指标结构 assert "total_tasks" in data or "metrics" in data def test_list_experiences(self, api_client: httpx.Client): resp = api_client.get("/api/v1/evolution-dashboard/experiences") assert resp.status_code == 200 data = resp.json() assert "experiences" in data assert "total" in data def test_get_metrics_no_api_key(self, e2e_server: str): """dashboard 端点自带 _verify_api_key,无 key 时返回 401。""" client = httpx.Client(base_url=e2e_server, timeout=10) resp = client.get("/api/v1/evolution-dashboard/metrics") assert resp.status_code == 401 def test_pitfalls_missing_required_param(self, api_client: httpx.Client): """pitfalls 端点 task_type 为必填,缺失时 422。""" resp = api_client.get("/api/v1/evolution-dashboard/pitfalls") assert resp.status_code == 422 # ═══════════════════════════════════════════════════════════════════════════ # 7. Settings API — /api/v1/settings # GET 不需特殊权限;PUT 需 SYSTEM_CONFIG(operator 无 → 403) # ═══════════════════════════════════════════════════════════════════════════ class TestSettingsAPI: def test_get_llm_settings(self, api_client: httpx.Client): resp = api_client.get("/api/v1/settings/llm") assert resp.status_code == 200 data = resp.json() assert "providers" in data def test_get_skills_settings(self, api_client: httpx.Client): resp = api_client.get("/api/v1/settings/skills") assert resp.status_code == 200 data = resp.json() assert "paths" in data def test_get_general_settings(self, api_client: httpx.Client): resp = api_client.get("/api/v1/settings/general") assert resp.status_code == 200 def test_update_llm_settings_forbidden(self, api_client: httpx.Client): """PUT /settings/llm 需 SYSTEM_CONFIG;api_client 为 operator 角色 → 403。""" resp = api_client.put( "/api/v1/settings/llm", json={"providers": []}, ) # operator 无 SYSTEM_CONFIG;非 dev mode 高风险权限 → 403 assert resp.status_code in (401, 403) # ═══════════════════════════════════════════════════════════════════════════ # 8. Auth API — /api/v1/auth # login/whoami 在中间件白名单内(不需要 X-API-Key) # ═══════════════════════════════════════════════════════════════════════════ class TestAuthAPI: def test_login_invalid_credentials(self, e2e_server: str): """login 端点白名单,无需 API key;无效凭据 → 401。""" client = httpx.Client(base_url=e2e_server, timeout=10) resp = client.post( "/api/v1/auth/login", json={"username": "no_such_user", "password": "wrong_password"}, ) assert resp.status_code == 401 def test_whoami_without_bearer(self, e2e_server: str): """whoami 自行校验 bearer token;缺失 → 401。""" client = httpx.Client(base_url=e2e_server, timeout=10) resp = client.get("/api/v1/auth/whoami") assert resp.status_code == 401 def test_login_missing_fields(self, e2e_server: str): """login 请求体缺少必填字段 → 422。""" client = httpx.Client(base_url=e2e_server, timeout=10) resp = client.post("/api/v1/auth/login", json={}) assert resp.status_code == 422 # ═══════════════════════════════════════════════════════════════════════════ # 9. Memory API — /api/v1/memory # E2E 配置无 memory 段,retriever 未设置 → 503 # ═══════════════════════════════════════════════════════════════════════════ class TestMemoryAPI: def test_search_episodic_retriever_unconfigured(self, api_client: httpx.Client): """retriever 未配置 → 503。""" resp = api_client.get("/api/v1/memory/episodic", params={"query": "test"}) assert resp.status_code == 503 def test_search_semantic_retriever_unconfigured(self, api_client: httpx.Client): resp = api_client.get( "/api/v1/memory/semantic/search", params={"query": "test"} ) assert resp.status_code == 503 def test_search_episodic_missing_query(self, api_client: httpx.Client): """query 为必填 query 参数,缺失 → 422。""" resp = api_client.get("/api/v1/memory/episodic") assert resp.status_code == 422