初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+481
View File
@@ -0,0 +1,481 @@
"""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