"""API 集成测试。""" from collections.abc import AsyncGenerator import pytest from httpx import ASGITransport, AsyncClient from app.ai_provider.types.chat import ChatChunk from app.api.v1.deps import require_admin from app.application.agent.runtime import AgentTurnResult from app.application.chat.send_message import drain_pending_streams from app.main import app @pytest.fixture async def client( monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str] ) -> AsyncGenerator[AsyncClient, None]: async def fake_run_agent_turn(*, on_chunk, **_kwargs: object): await on_chunk(ChatChunk(delta_content="你好,我是测试模型回复。")) return AgentTurnResult(content="你好,我是测试模型回复。", reasoning="") monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn) transport = ASGITransport(app=app) async with AsyncClient( transport=transport, base_url="http://test", headers=auth_headers, ) as http_client: yield http_client @pytest.mark.asyncio async def test_health_and_defaults(client: AsyncClient) -> None: health = await client.get("/api/v1/health") assert health.status_code == 200 character = await client.get("/api/v1/characters/active") assert character.status_code == 200 assert character.json()["id"].startswith("character_") personality = await client.get("/api/v1/personality/current") assert personality.status_code == 200 @pytest.mark.asyncio async def test_chat_run_and_memory_crud(client: AsyncClient) -> None: created = await client.post("/api/v1/chat/sessions", json={"title": "测试"}) assert created.status_code == 200 session_id = created.json()["session_id"] sent = await client.post( f"/api/v1/chat/sessions/{session_id}/messages", json={"content": "你好", "modality": "text"}, ) assert sent.status_code == 200 run_id = sent.json()["run_id"] await drain_pending_streams() run = await client.get(f"/api/v1/runs/{run_id}") assert run.status_code == 200 assert run.json()["status"] == "completed" memory = await client.post( "/api/v1/memory", json={"type": "user", "title": "偏好", "content": "用户喜欢简洁回答。"}, ) assert memory.status_code == 200 memory_id = memory.json()["id"] listed = await client.get("/api/v1/memory") assert listed.status_code == 200 assert listed.json()["total"] == 1 updated = await client.patch(f"/api/v1/memory/{memory_id}", json={"importance": 0.9}) assert updated.status_code == 200 deleted = await client.delete(f"/api/v1/memory/{memory_id}") assert deleted.status_code == 200 assert deleted.json()["status"] == "deleted" @pytest.mark.asyncio async def test_chat_session_and_messages_query_api(client: AsyncClient) -> None: created = await client.post("/api/v1/chat/sessions", json={"title": "查询测试"}) assert created.status_code == 200 created_body = created.json() session_id = created_body["session_id"] listed = await client.get("/api/v1/chat/sessions") assert listed.status_code == 200 listed_body = listed.json() assert listed_body["total"] >= 1 assert any(item["session_id"] == session_id for item in listed_body["items"]) fetched = await client.get(f"/api/v1/chat/sessions/{session_id}") assert fetched.status_code == 200 fetched_body = fetched.json() assert fetched_body["session_id"] == session_id assert fetched_body["title"] == "查询测试" assert fetched_body["status"] == "active" sent = await client.post( f"/api/v1/chat/sessions/{session_id}/messages", json={"content": "查询消息", "modality": "text"}, ) assert sent.status_code == 200 # 流式化后 send_message 立即返回,assistant_message_id 此刻为空,需等待后台任务定稿。 await drain_pending_streams() messages = await client.get(f"/api/v1/chat/sessions/{session_id}/messages") assert messages.status_code == 200 messages_body = messages.json() assert messages_body["total"] == 2 assert [item["role"] for item in messages_body["items"]] == ["user", "assistant"] assert messages_body["items"][0]["content"] == "查询消息" assert messages_body["items"][1]["message_id"].startswith("message_") missing = await client.get("/api/v1/chat/sessions/session_missing") assert missing.status_code == 404 assert missing.json()["error"]["code"] == "not_found" @pytest.mark.asyncio async def test_chat_session_update_and_delete(client: AsyncClient) -> None: created = await client.post("/api/v1/chat/sessions", json={"title": "待删除"}) assert created.status_code == 200 session_id = created.json()["session_id"] # 发两条消息制造关联数据(覆盖 messages 软删路径) sent = await client.post( f"/api/v1/chat/sessions/{session_id}/messages", json={"content": "占位消息", "modality": "text"}, ) assert sent.status_code == 200 await drain_pending_streams() messages_before = await client.get(f"/api/v1/chat/sessions/{session_id}/messages") assert messages_before.status_code == 200 assert messages_before.json()["total"] == 2 # PATCH 重命名 renamed = await client.patch(f"/api/v1/chat/sessions/{session_id}", json={"title": "重命名后"}) assert renamed.status_code == 200 assert renamed.json()["title"] == "重命名后" assert renamed.json()["status"] == "active" # PATCH 归档 archived = await client.patch( f"/api/v1/chat/sessions/{session_id}", json={"status": "archived"} ) assert archived.status_code == 200 assert archived.json()["status"] == "archived" # PATCH 禁止直接置 deleted bad = await client.patch(f"/api/v1/chat/sessions/{session_id}", json={"status": "deleted"}) assert bad.status_code == 400 # DELETE 软删除 → 204 deleted = await client.delete(f"/api/v1/chat/sessions/{session_id}") assert deleted.status_code == 204 # 删除后 GET 该会话 404 gone = await client.get(f"/api/v1/chat/sessions/{session_id}") assert gone.status_code == 404 # 列表不再包含 listed = await client.get("/api/v1/chat/sessions") assert listed.status_code == 200 assert not any(item["session_id"] == session_id for item in listed.json()["items"]) # 消息也被软删:总条数为 0 messages_after = await client.get(f"/api/v1/chat/sessions/{session_id}/messages") assert messages_after.status_code == 404 # 会话已 deleted,查询应拒绝 # 重复删除仍返回 404 repeat = await client.delete(f"/api/v1/chat/sessions/{session_id}") assert repeat.status_code == 404 @pytest.mark.asyncio async def test_personality_propose_approve_reject_and_rollback(client: AsyncClient) -> None: # 初始当前人格为 v1 before = (await client.get("/api/v1/personality/current")).json() assert before["version"] == 1 # 空字段提议被拒(invalid_request) empty = await client.post( "/api/v1/personality/propose-update", json={"fields": {}, "reason": "空提议"}, ) assert empty.status_code == 400 # 提交 pending 建议(覆盖 string + json 字段) propose = await client.post( "/api/v1/personality/propose-update", json={ "fields": {"identity_profile": "测试身份", "traits": {"warmth": 0.5}}, "reason": "联调人格建议", }, ) assert propose.status_code == 201 proposal = propose.json() assert proposal["status"] == "pending" assert proposal["fields"] == {"identity_profile": "测试身份", "traits": {"warmth": 0.5}} proposal_id = proposal["proposal_id"] # 审核通过:派生新版本并切换当前指针,字段覆盖生效 approve = await client.post(f"/api/v1/personality/proposals/{proposal_id}/approve") assert approve.status_code == 200 applied = approve.json() assert applied["status"] == "applied" assert applied["resulting_version_id"] after = (await client.get("/api/v1/personality/current")).json() assert after["version"] == before["version"] + 1 assert after["identity_profile"] == "测试身份" assert after["traits"]["warmth"] == 0.5 new_version_id = after["id"] # 重复审核同一建议 → conflict again = await client.post(f"/api/v1/personality/proposals/{proposal_id}/approve") assert again.status_code == 409 # 驳回另一条建议:不生成版本,不改当前指针 propose2 = await client.post( "/api/v1/personality/propose-update", json={"fields": {"expression_style": "被驳回"}, "reason": "测试驳回"}, ) pid2 = propose2.json()["proposal_id"] reject = await client.post(f"/api/v1/personality/proposals/{pid2}/reject") assert reject.status_code == 200 assert reject.json()["status"] == "rejected" assert (await client.get("/api/v1/personality/current")).json()["id"] == new_version_id # 回滚到 v1:当前指针切回,旧版本数不变 versions = (await client.get("/api/v1/personality/versions")).json() v1 = next(v for v in versions["items"] if v["version"] == 1) rollback = await client.post(f"/api/v1/personality/versions/{v1['id']}/rollback") assert rollback.status_code == 200 assert (await client.get("/api/v1/personality/current")).json()["id"] == v1["id"] # 回滚到不存在的版本 → 404 missing = await client.post("/api/v1/personality/versions/personality_nope/rollback") assert missing.status_code == 404 # 写操作均入起居注(personality_core 枢) # /admin/* 强制 require_admin 鉴权,联调测试在此局部绕过(鉴权本身覆盖见 test_admin_auth) app.dependency_overrides[require_admin] = lambda: None try: audit = await client.get("/api/v1/admin/audit/timeline?pivot=personality_core") assert audit.status_code == 200 actions = {item["action"] for item in audit.json()["items"]} assert "personality.propose" in actions assert "personality.proposal.approved" in actions assert "personality.proposal.rejected" in actions assert "personality.rollback" in actions finally: app.dependency_overrides.pop(require_admin, None) @pytest.mark.asyncio async def test_message_version_selection(client: AsyncClient) -> None: """选择/激活消息版本——覆盖 services/chat.select_message_version 路径。""" created = await client.post("/api/v1/chat/sessions", json={"title": "版本测试"}) assert created.status_code == 200 session_id = created.json()["session_id"] sent = await client.post( f"/api/v1/chat/sessions/{session_id}/messages", json={"content": "原始内容", "modality": "text"}, ) assert sent.status_code == 200 await drain_pending_streams() messages = await client.get(f"/api/v1/chat/sessions/{session_id}/messages") assert messages.status_code == 200 items = messages.json()["items"] assert len(items) == 2 assistant_msg = next(m for m in items if m["role"] == "assistant") msg_id = assistant_msg["message_id"] # 选择不存在的版本索引 → 400 bad = await client.patch( f"/api/v1/chat/sessions/{session_id}/messages/{msg_id}/version", json={"version_idx": 99}, ) assert bad.status_code == 400 # 选择索引 0(当前版本) ok = await client.patch( f"/api/v1/chat/sessions/{session_id}/messages/{msg_id}/version", json={"version_idx": 0}, ) assert ok.status_code == 200 @pytest.mark.asyncio async def test_skills_crud(client: AsyncClient) -> None: """Skill 能力包 CRUD——覆盖 services/skills 路径。""" created = await client.post( "/api/v1/tools/skills", json={ "name": "测试技能", "description": "用于集成测试", "trigger": "测试触发", "prompt_fragment": "测试提示片段", "tool_bindings": [], "risk_level": "L0", }, ) assert created.status_code == 201 skill_id = created.json()["id"] listed = await client.get("/api/v1/tools/skills") assert listed.status_code == 200 assert any(item["id"] == skill_id for item in listed.json()) # Skills 没有 GET single 端点,跳过 updated = await client.patch( f"/api/v1/tools/skills/{skill_id}", json={"description": "改描述", "trigger": "新触发"}, ) assert updated.status_code == 200 assert updated.json()["trigger"] == "新触发" deleted = await client.delete(f"/api/v1/tools/skills/{skill_id}") assert deleted.status_code == 204 @pytest.mark.asyncio async def test_mcp_server_crud(client: AsyncClient) -> None: """MCP 服务器注册 CRUD——覆盖 services/mcp 路径。""" created = await client.post( "/api/v1/tools/mcp-servers", json={ "name": "测试 MCP", "transport": "stdio", "command": "echo", "args_json": '["hello"]', }, ) assert created.status_code == 201 mcp_id = created.json()["id"] listed = await client.get("/api/v1/tools/mcp-servers") assert listed.status_code == 200 assert any(item["id"] == mcp_id for item in listed.json()) # MCP 服务器没有 GET single 端点,跳过 # PATCH: UpdateMcpServerRequest 不含 name 字段,改 command 验证路径可通 updated = await client.patch( f"/api/v1/tools/mcp-servers/{mcp_id}", json={"command": "cat", "args_json": '["-n"]'}, ) assert updated.status_code == 200 assert updated.json()["command"] == "cat" deleted = await client.delete(f"/api/v1/tools/mcp-servers/{mcp_id}") assert deleted.status_code == 204 @pytest.mark.asyncio async def test_world_entities_crud(client: AsyncClient) -> None: """河图世界实体 CRUD——覆盖 services/world 路径。""" created = await client.post( "/api/v1/world/entities", json={ "name": "测试地点", "type": "location", "attributes": {"climate": "温暖"}, "state_type": "fixed", "sources": [], }, ) assert created.status_code == 200 entity_id = created.json()["id"] listed = await client.get("/api/v1/world/entities") assert listed.status_code == 200 assert len(listed.json()["items"]) >= 1 fetched = await client.get(f"/api/v1/world/entities/{entity_id}") assert fetched.status_code == 200 assert fetched.json()["name"] == "测试地点" # 世界实体无 PATCH/DELETE API,仅测试 GET 和建表 deleted = await client.delete(f"/api/v1/world/entities/{entity_id}") assert deleted.status_code in (404, 405) # 无删除端点,接受未实现 gone = await client.get(f"/api/v1/world/entities/{entity_id}") assert gone.status_code in (200, 404) @pytest.mark.asyncio async def test_memory_list_with_filters(client: AsyncClient) -> None: """记忆列表支持类型/来源过滤——覆盖 services/memory.list_memory_page 路径。""" m1 = await client.post( "/api/v1/memory", json={ "type": "observation", "title": "观测记忆", "content": "一条观测记忆", "source": "observation", }, ) assert m1.status_code == 200 m2 = await client.post( "/api/v1/memory", json={ "type": "reflection", "title": "反思记忆", "content": "一条反思记忆", "source": "reflection", }, ) assert m2.status_code == 200 # 按类型过滤 obs = await client.get("/api/v1/memory?type=observation") assert obs.status_code == 200 assert all(item["type"] == "observation" for item in obs.json()["items"]) # 按来源过滤 ref = await client.get("/api/v1/memory?source=reflection") assert ref.status_code == 200 assert all(item["source"] == "reflection" for item in ref.json()["items"]) # 总量 >= 2 all_mem = await client.get("/api/v1/memory") assert all_mem.status_code == 200 assert all_mem.json()["total"] >= 2 @pytest.mark.asyncio async def test_safety_policy_crud(client: AsyncClient) -> None: """安全策略 CRUD——覆盖 services/governance 部分路径。""" import uuid policy_name = f"test-policy-{uuid.uuid4().hex[:8]}" created = await client.post( "/api/v1/safety/policies", json={ "name": policy_name, "description": "集成测试策略", "risk_level": "L3", "permission_policy": "ask", "side_effect": True, "audit_required": True, }, ) assert created.status_code == 201, created.text data = created.json() assert data["name"] == policy_name listed = await client.get("/api/v1/safety/policies") assert listed.status_code == 200 # SafetyPolicyItem 使用 tool 字段名(不是 name) assert any(item["tool"] == policy_name for item in listed.json()) updated = await client.patch( f"/api/v1/safety/policies/{policy_name}", json={"description": "改后描述", "risk_level": "L2"}, ) assert updated.status_code == 200, updated.text assert updated.json()["risk_level"] == "L2" deleted = await client.delete(f"/api/v1/safety/policies/{policy_name}") assert deleted.status_code == 204 @pytest.mark.asyncio async def test_preferences_flow(client: AsyncClient) -> None: """偏好设置读写——覆盖 services/preferences 路径。""" fetched = await client.get("/api/v1/settings/preferences") assert fetched.status_code == 200 assert "temperature" in fetched.json() updated = await client.patch( "/api/v1/settings/preferences", json={"temperature": 0.7, "max_output_tokens": 4096}, ) assert updated.status_code == 200 assert updated.json()["temperature"] == 0.7 assert updated.json()["max_output_tokens"] == 4096