chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — Wingman API 端点测试
|
||||
# =============================================================================
|
||||
# 测试覆盖:
|
||||
# 1. POST /api/conversations/{id}/wingman/draft — 正常/认证/404/降级
|
||||
# 2. POST /api/conversations/{id}/wingman/summary — 正常/认证/404/降级
|
||||
# 3. POST /api/conversations/{id}/wingman/tags — 正常/认证/404/降级
|
||||
# =============================================================================
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.agent import Agent
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.services.wingman_service import WingmanService
|
||||
from app.dependencies import dep_wingman_service
|
||||
from app.database import get_db
|
||||
from tests.conftest import create_test_conversation, create_test_agent, MockRedis
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def wingman_client(db_session: AsyncSession, mock_redis: MockRedis):
|
||||
"""提供配置了 Wingman 路由和 mock 服务的 FastAPI 测试客户端。"""
|
||||
|
||||
# 创建 mock WingmanService
|
||||
mock_wingman = MagicMock(spec=WingmanService)
|
||||
mock_wingman.close = AsyncMock()
|
||||
|
||||
async def _override_get_db():
|
||||
yield db_session
|
||||
|
||||
async def _override_dep_wingman():
|
||||
return mock_wingman
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
# 覆盖数据库依赖
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
# 覆盖 Wingman 服务依赖
|
||||
app.dependency_overrides[dep_wingman_service] = _override_dep_wingman
|
||||
|
||||
# 模拟 Redis(认证依赖需要)
|
||||
# 注意:h5.py 已重构移除 _get_redis,不再需要 patch 它
|
||||
with patch("app.api.agents._get_redis", return_value=mock_redis):
|
||||
with patch("redis.asyncio.from_url", return_value=mock_redis):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as ac:
|
||||
# 将 mock_wingman 附加到 client 上,方便测试中配置行为
|
||||
ac._mock_wingman = mock_wingman
|
||||
yield ac
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def authed_agent(db_session: AsyncSession, mock_redis: MockRedis):
|
||||
"""创建一个已登录的坐席并返回(agent, token)。"""
|
||||
agent = create_test_agent(user_id="wingman_test_agent", name="测试坐席")
|
||||
db_session.add(agent)
|
||||
await db_session.flush()
|
||||
|
||||
# 在 mock Redis 中存储 token
|
||||
token = "test-wingman-token-001"
|
||||
await mock_redis.setex(
|
||||
f"agent:token:{token}", 28800, "wingman_test_agent"
|
||||
)
|
||||
|
||||
return agent, token
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_conversation(db_session: AsyncSession, authed_agent):
|
||||
"""创建一个测试会话并返回。"""
|
||||
agent, _ = authed_agent
|
||||
conv = create_test_conversation(
|
||||
status="serving",
|
||||
)
|
||||
conv.assigned_agent_id = agent.user_id
|
||||
db_session.add(conv)
|
||||
await db_session.flush()
|
||||
|
||||
# 添加一些消息
|
||||
msg1 = Message(
|
||||
conversation_id=conv.id,
|
||||
sender_type="employee",
|
||||
sender_id="emp001",
|
||||
sender_name="员工张三",
|
||||
content="VPN连不上怎么办",
|
||||
msg_type="text",
|
||||
)
|
||||
msg2 = Message(
|
||||
conversation_id=conv.id,
|
||||
sender_type="agent",
|
||||
sender_id=agent.user_id,
|
||||
sender_name=agent.name,
|
||||
content="请问报什么错误",
|
||||
msg_type="text",
|
||||
)
|
||||
db_session.add_all([msg1, msg2])
|
||||
await db_session.flush()
|
||||
|
||||
return conv
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Draft API 测试
|
||||
# =============================================================================
|
||||
class TestDraftAPI:
|
||||
"""测试 POST /api/conversations/{id}/wingman/draft 端点。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_success(
|
||||
self, wingman_client, authed_agent, test_conversation
|
||||
):
|
||||
"""正常路径:已认证坐席为存在的会话生成草稿。"""
|
||||
agent, token = authed_agent
|
||||
conv = test_conversation
|
||||
mock_wingman = wingman_client._mock_wingman
|
||||
|
||||
# 配置 mock WingmanService
|
||||
mock_wingman.generate_draft = AsyncMock(
|
||||
return_value={
|
||||
"content": "请尝试重启VPN客户端",
|
||||
"confidence": 0.85,
|
||||
"reasoning": "基于最近 2 条对话上下文生成",
|
||||
}
|
||||
)
|
||||
|
||||
response = await wingman_client.post(
|
||||
f"/conversations/{conv.id}/wingman/draft",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 0
|
||||
assert data["data"]["content"] == "请尝试重启VPN客户端"
|
||||
assert data["data"]["confidence"] == 0.85
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_unauthorized(self, wingman_client, test_conversation):
|
||||
"""认证验证:未登录坐席访问应返回错误码 1002。"""
|
||||
conv = test_conversation
|
||||
|
||||
response = await wingman_client.post(
|
||||
f"/conversations/{conv.id}/wingman/draft",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
assert data["code"] == 1002 # ERR_UNAUTHORIZED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_conversation_not_found(
|
||||
self, wingman_client, authed_agent
|
||||
):
|
||||
"""会话不存在:传入不存在的 conversation_id 应返回错误码 1003。"""
|
||||
_, token = authed_agent
|
||||
fake_id = str(uuid.uuid4())
|
||||
|
||||
response = await wingman_client.post(
|
||||
f"/conversations/{fake_id}/wingman/draft",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
assert data["code"] == 1003 # ERR_NOT_FOUND
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_wingman_degradation(
|
||||
self, wingman_client, authed_agent, test_conversation
|
||||
):
|
||||
"""Wingman 降级:WingmanService 返回降级结果时,API 不应 500。"""
|
||||
agent, token = authed_agent
|
||||
conv = test_conversation
|
||||
mock_wingman = wingman_client._mock_wingman
|
||||
|
||||
# 配置 mock 返回降级结果
|
||||
mock_wingman.generate_draft = AsyncMock(
|
||||
return_value={
|
||||
"content": "",
|
||||
"confidence": 0.0,
|
||||
"reasoning": "Wingman 服务暂不可用",
|
||||
}
|
||||
)
|
||||
|
||||
response = await wingman_client.post(
|
||||
f"/conversations/{conv.id}/wingman/draft",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 0
|
||||
assert data["data"]["content"] == ""
|
||||
assert data["data"]["confidence"] == 0.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Summary API 测试
|
||||
# =============================================================================
|
||||
class TestSummaryAPI:
|
||||
"""测试 POST /api/conversations/{id}/wingman/summary 端点。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_success(
|
||||
self, wingman_client, authed_agent, test_conversation
|
||||
):
|
||||
"""正常路径:已认证坐席为存在的会话生成摘要。"""
|
||||
agent, token = authed_agent
|
||||
conv = test_conversation
|
||||
mock_wingman = wingman_client._mock_wingman
|
||||
|
||||
mock_wingman.generate_summary = AsyncMock(
|
||||
return_value={
|
||||
"problem": "VPN连接失败",
|
||||
"cause": "证书过期",
|
||||
"solution": "更新VPN证书并重启客户端",
|
||||
}
|
||||
)
|
||||
|
||||
response = await wingman_client.post(
|
||||
f"/conversations/{conv.id}/wingman/summary",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 0
|
||||
assert data["data"]["problem"] == "VPN连接失败"
|
||||
assert data["data"]["cause"] == "证书过期"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_unauthorized(self, wingman_client, test_conversation):
|
||||
"""认证验证:未登录坐席访问应返回错误码 1002。"""
|
||||
conv = test_conversation
|
||||
|
||||
response = await wingman_client.post(
|
||||
f"/conversations/{conv.id}/wingman/summary",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
assert data["code"] == 1002
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_conversation_not_found(
|
||||
self, wingman_client, authed_agent
|
||||
):
|
||||
"""会话不存在:传入不存在的 conversation_id 应返回错误码 1003。"""
|
||||
_, token = authed_agent
|
||||
fake_id = str(uuid.uuid4())
|
||||
|
||||
response = await wingman_client.post(
|
||||
f"/conversations/{fake_id}/wingman/summary",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
assert data["code"] == 1003
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_wingman_degradation(
|
||||
self, wingman_client, authed_agent, test_conversation
|
||||
):
|
||||
"""Wingman 降级:WingmanService 返回降级摘要时,API 不应 500。"""
|
||||
agent, token = authed_agent
|
||||
conv = test_conversation
|
||||
mock_wingman = wingman_client._mock_wingman
|
||||
|
||||
mock_wingman.generate_summary = AsyncMock(
|
||||
return_value={
|
||||
"problem": "无法自动生成摘要",
|
||||
"cause": "",
|
||||
"solution": "",
|
||||
}
|
||||
)
|
||||
|
||||
response = await wingman_client.post(
|
||||
f"/conversations/{conv.id}/wingman/summary",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 0
|
||||
assert data["data"]["problem"] == "无法自动生成摘要"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tags API 测试
|
||||
# =============================================================================
|
||||
class TestTagsAPI:
|
||||
"""测试 POST /api/conversations/{id}/wingman/tags 端点。"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tags_success(
|
||||
self, wingman_client, authed_agent, test_conversation
|
||||
):
|
||||
"""正常路径:已认证坐席为存在的会话生成标签建议。"""
|
||||
agent, token = authed_agent
|
||||
conv = test_conversation
|
||||
mock_wingman = wingman_client._mock_wingman
|
||||
|
||||
mock_wingman.suggest_tags = AsyncMock(
|
||||
return_value={
|
||||
"suggested_tags": ["VPN", "网络"],
|
||||
"category": "网络",
|
||||
"priority": "high",
|
||||
}
|
||||
)
|
||||
|
||||
response = await wingman_client.post(
|
||||
f"/conversations/{conv.id}/wingman/tags",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 0
|
||||
assert data["data"]["suggested_tags"] == ["VPN", "网络"]
|
||||
assert data["data"]["priority"] == "high"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tags_unauthorized(self, wingman_client, test_conversation):
|
||||
"""认证验证:未登录坐席访问应返回错误码 1002。"""
|
||||
conv = test_conversation
|
||||
|
||||
response = await wingman_client.post(
|
||||
f"/conversations/{conv.id}/wingman/tags",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
assert data["code"] == 1002
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tags_conversation_not_found(
|
||||
self, wingman_client, authed_agent
|
||||
):
|
||||
"""会话不存在:传入不存在的 conversation_id 应返回错误码 1003。"""
|
||||
_, token = authed_agent
|
||||
fake_id = str(uuid.uuid4())
|
||||
|
||||
response = await wingman_client.post(
|
||||
f"/conversations/{fake_id}/wingman/tags",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
assert data["code"] == 1003
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tags_wingman_degradation(
|
||||
self, wingman_client, authed_agent, test_conversation
|
||||
):
|
||||
"""Wingman 降级:WingmanService 返回降级标签时,API 不应 500。"""
|
||||
agent, token = authed_agent
|
||||
conv = test_conversation
|
||||
mock_wingman = wingman_client._mock_wingman
|
||||
|
||||
mock_wingman.suggest_tags = AsyncMock(
|
||||
return_value={
|
||||
"suggested_tags": [],
|
||||
"category": "",
|
||||
"priority": "medium",
|
||||
}
|
||||
)
|
||||
|
||||
response = await wingman_client.post(
|
||||
f"/conversations/{conv.id}/wingman/tags",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["code"] == 0
|
||||
assert data["data"]["suggested_tags"] == []
|
||||
assert data["data"]["priority"] == "medium"
|
||||
Reference in New Issue
Block a user