- ① 后端删除 app/ai_provider/(38 文件,含 anthropic/openai 格式、billing、audio、embedding)、services/ai_provider_admin.py、core/config/model_config.py、tests/ai_provider/(21 文件) - ② 连带退役 application/agent/(旧 Agent 循环/steer/follow-up/确认注册表)、application/skill_executor.py、app/skills/(python skills 孤儿)、endpoints/audio.py、application/imports.py(ProviderManager 消费方);settings 端点收敛为生成偏好 - ③ runs.py 修剪:AgentRunRegistry/steer/follow-up/confirmations 端点退役,保留 list/get/events/cancel/stream;safety 确认改为直接落库裁决(pending→approved/denied) - ④ 前端删除:models 页、ModelGatewayView、gateway.ts、GatewayPanel(玄关)、ModelGatewayPanel/ModelStrategyForm、audio.ts/useVoiceInput、网关类型与 mock;设置页收敛为外观/语言/通知 - ⑤ 配置收敛:config/models|providers|pricing.example.yaml 归档 shenshi/config/;.env.example 删 provider keys/MODEL_ENDPOINT_POOL/MODEL_CONFIG_PATH,收敛 SHENSHI_* - ⑥ 验收:rg 审计灵台 Provider SDK import 与 ai_provider 引用归零;后端 528 passed(2 个预存失败经 HEAD 复跑验证与本手术无关)、前端 type-check+201 passed、神识 go vet+test、BFF 4 passed
267 lines
9.6 KiB
Python
267 lines
9.6 KiB
Python
"""API 集成测试。"""
|
||
|
||
from collections.abc import AsyncGenerator
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
|
||
from app.api.v1.deps import require_admin
|
||
from app.main import app
|
||
|
||
|
||
@pytest.fixture
|
||
async def client(
|
||
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
||
) -> AsyncGenerator[AsyncClient, None]:
|
||
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_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_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_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
|