5e53146a9a
新增自动化审批状态机/执行器/意图路由/会话管理、OTP 绑定流程、neo4j 客户端、响应契约、置信度门禁、环境门控、Tier1 API 等测试。
628 lines
21 KiB
Python
628 lines
21 KiB
Python
# =============================================================================
|
|
# 企微IT智能服务台 — 阶段5 自动化闭环 服务单元测试
|
|
# =============================================================================
|
|
# 说明:测试核心服务:session_manager、intent_router、executor、approval
|
|
# 创建日期: 2026-07-01
|
|
# =============================================================================
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from datetime import datetime
|
|
|
|
from app.models.automation import (
|
|
AutoSession,
|
|
AutoAction,
|
|
ApprovalTicket,
|
|
ScenarioConfig,
|
|
)
|
|
from app.services.automation.session_manager import AutoSessionService
|
|
from app.services.automation.intent_router import IntentRouter
|
|
from app.services.automation.executor import ActionExecutor
|
|
from app.services.automation.approval import ApprovalService
|
|
from app.services.automation.exception_handler import AutomationException
|
|
from app.constants import AutomationErrorCode
|
|
|
|
|
|
# =============================================================================
|
|
# 测试 ApprovalService
|
|
# =============================================================================
|
|
class TestApprovalService:
|
|
"""审批单服务测试"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_ticket_idempotent(self, db_session):
|
|
"""测试 ensure_ticket 幂等性:同一动作多次调用应返回同一审批单"""
|
|
# 创建会话和动作
|
|
session = AutoSession(
|
|
conversation_id="test_conv_001",
|
|
employee_id="emp_001",
|
|
title="测试会话",
|
|
status="running",
|
|
)
|
|
db_session.add(session)
|
|
await db_session.flush()
|
|
|
|
action = AutoAction(
|
|
session_id=session.id,
|
|
action_index=0,
|
|
action_type="virus_scan",
|
|
risk_level="high",
|
|
title="病毒扫描",
|
|
description="扫描终端",
|
|
status="pending",
|
|
)
|
|
db_session.add(action)
|
|
await db_session.flush()
|
|
|
|
# 第一次调用 ensure_ticket
|
|
svc = ApprovalService(db_session)
|
|
ticket1 = await svc.ensure_ticket(action, channel="agent", reason="高危操作")
|
|
|
|
# 第二次调用 ensure_ticket(幂等性测试)
|
|
ticket2 = await svc.ensure_ticket(action, channel="agent", reason="高危操作")
|
|
|
|
assert ticket1.id == ticket2.id, "幂等性:同一动作应返回同一审批单"
|
|
assert ticket1.status == "pending", "审批单状态应为 pending"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_ticket_creates_new_when_no_pending(self, db_session):
|
|
"""测试 ensure_ticket 创建新审批单"""
|
|
session = AutoSession(
|
|
conversation_id="test_conv_002",
|
|
employee_id="emp_002",
|
|
title="测试会话2",
|
|
status="running",
|
|
)
|
|
db_session.add(session)
|
|
await db_session.flush()
|
|
|
|
action = AutoAction(
|
|
session_id=session.id,
|
|
action_index=0,
|
|
action_type="virus_quarantine",
|
|
risk_level="high",
|
|
title="病毒隔离",
|
|
description="隔离终端",
|
|
status="pending",
|
|
)
|
|
db_session.add(action)
|
|
await db_session.flush()
|
|
|
|
svc = ApprovalService(db_session)
|
|
ticket = await svc.ensure_ticket(action, channel="h5", reason="需要员工确认")
|
|
|
|
assert ticket is not None, "应创建审批单"
|
|
assert ticket.action_id == action.id, "审批单应关联动作"
|
|
assert ticket.channel == "h5", "渠道应为 h5"
|
|
assert ticket.status == "pending", "状态应为 pending"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decide_approve(self, db_session):
|
|
"""测试审批通过"""
|
|
session = AutoSession(
|
|
conversation_id="test_conv_003",
|
|
employee_id="emp_003",
|
|
title="测试会话3",
|
|
status="running",
|
|
)
|
|
db_session.add(session)
|
|
await db_session.flush()
|
|
|
|
action = AutoAction(
|
|
session_id=session.id,
|
|
action_index=0,
|
|
action_type="password_reset_link",
|
|
risk_level="high",
|
|
title="密码重置",
|
|
description="重置密码",
|
|
status="pending",
|
|
)
|
|
db_session.add(action)
|
|
await db_session.flush()
|
|
|
|
svc = ApprovalService(db_session)
|
|
ticket = await svc.ensure_ticket(action, channel="agent", reason="重置密码")
|
|
|
|
# 审批通过
|
|
updated = await svc.decide(ticket.id, "approve", "同意", "agent_001")
|
|
|
|
assert updated.status == "approved", "审批单状态应为 approved"
|
|
assert updated.decision_note == "同意", "应有审批意见"
|
|
assert updated.approver_id == "agent_001", "应有审批人"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decide_reject(self, db_session):
|
|
"""测试审批驳回"""
|
|
session = AutoSession(
|
|
conversation_id="test_conv_004",
|
|
employee_id="emp_004",
|
|
title="测试会话4",
|
|
status="running",
|
|
)
|
|
db_session.add(session)
|
|
await db_session.flush()
|
|
|
|
action = AutoAction(
|
|
session_id=session.id,
|
|
action_index=0,
|
|
action_type="virus_quarantine",
|
|
risk_level="high",
|
|
title="病毒隔离",
|
|
description="隔离终端",
|
|
status="pending",
|
|
)
|
|
db_session.add(action)
|
|
await db_session.flush()
|
|
|
|
svc = ApprovalService(db_session)
|
|
ticket = await svc.ensure_ticket(action, channel="agent", reason="隔离终端")
|
|
|
|
# 审批驳回
|
|
updated = await svc.decide(ticket.id, "reject", "风险过高", "agent_002")
|
|
|
|
assert updated.status == "rejected", "审批单状态应为 rejected"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decide_nonexistent_raises(self, db_session):
|
|
"""测试审批单不存在时抛出异常"""
|
|
svc = ApprovalService(db_session)
|
|
|
|
with pytest.raises(ValueError, match="审批单不存在"):
|
|
await svc.decide("nonexistent_ticket_id", "approve", "同意", "agent_001")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_pending_for_action(self, db_session):
|
|
"""测试查询动作待决审批单"""
|
|
session = AutoSession(
|
|
conversation_id="test_conv_005",
|
|
employee_id="emp_005",
|
|
title="测试会话5",
|
|
status="running",
|
|
)
|
|
db_session.add(session)
|
|
await db_session.flush()
|
|
|
|
action = AutoAction(
|
|
session_id=session.id,
|
|
action_index=0,
|
|
action_type="terminal_locate",
|
|
risk_level="read",
|
|
title="终端定位",
|
|
description="定位终端",
|
|
status="pending",
|
|
)
|
|
db_session.add(action)
|
|
await db_session.flush()
|
|
|
|
svc = ApprovalService(db_session)
|
|
ticket = await svc.ensure_ticket(action, channel="agent", reason="查询终端")
|
|
|
|
pending = await svc.get_pending_for_action(action.id)
|
|
assert pending is not None, "应能找到待决审批单"
|
|
assert pending.id == ticket.id, "应为同一审批单"
|
|
|
|
|
|
# =============================================================================
|
|
# 测试 IntentRouter
|
|
# =============================================================================
|
|
class TestIntentRouter:
|
|
"""意图识别路由测试"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_detect_with_fallback_intent(self):
|
|
"""测试关键词兜底意图识别"""
|
|
router = IntentRouter()
|
|
|
|
# 测试病毒相关关键词
|
|
result = await router.detect("电脑中病毒了", "emp_001")
|
|
|
|
assert result is not None, "应返回识别结果"
|
|
assert "scenario_key" in result, "应包含 scenario_key"
|
|
assert result.get("confidence") is not None, "应包含置信度"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_intent_password_reset(self):
|
|
"""测试密码重置关键词"""
|
|
router = IntentRouter()
|
|
|
|
result = await router.detect("我忘记密码了", "emp_002")
|
|
|
|
assert result.get("scenario_key") == "password_reset", "应识别为密码重置场景"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_intent_software_install(self):
|
|
"""测试软件安装关键词"""
|
|
router = IntentRouter()
|
|
|
|
result = await router.detect("帮我安装一个软件", "emp_003")
|
|
|
|
assert result.get("scenario_key") == "software_install", "应识别为软件安装场景"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_intent_terminal_locate(self):
|
|
"""测试终端定位关键词"""
|
|
router = IntentRouter()
|
|
|
|
result = await router.detect("我的电脑在哪", "emp_004")
|
|
|
|
assert result.get("scenario_key") == "terminal_locate", "应识别为终端定位场景"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fallback_intent_unknown(self):
|
|
"""测试未知意图返回空"""
|
|
router = IntentRouter()
|
|
|
|
result = await router.detect("今天天气不错", "emp_005")
|
|
|
|
assert result.get("scenario_key") is None, "未知意图应返回空场景"
|
|
assert result.get("confidence") == 0.0, "置信度应为0"
|
|
|
|
|
|
# =============================================================================
|
|
# 测试 AutoSessionService - 会话CRUD
|
|
# =============================================================================
|
|
class TestAutoSessionService:
|
|
"""自动化会话服务测试 - CRUD"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_session(self, db_session):
|
|
"""测试创建会话"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
session = await svc.create_session(
|
|
conversation_id="conv_001",
|
|
employee_id="emp_001",
|
|
description="测试会话",
|
|
mode="real_exec",
|
|
)
|
|
|
|
assert session is not None, "应创建会话"
|
|
assert session.employee_id == "emp_001", "员工ID应正确"
|
|
assert session.status == "created", "初始状态应为 created"
|
|
assert session.mode == "real_exec", "执行模式应正确"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_session(self, db_session):
|
|
"""测试获取会话"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
# 先创建
|
|
created = await svc.create_session(
|
|
conversation_id="conv_002",
|
|
employee_id="emp_002",
|
|
description="测试获取会话",
|
|
mode="real_exec",
|
|
)
|
|
|
|
# 再获取
|
|
retrieved = await svc.get_session(created.id)
|
|
|
|
assert retrieved is not None, "应能获取会话"
|
|
assert retrieved.id == created.id, "会话ID应一致"
|
|
assert retrieved.employee_id == "emp_002", "员工ID应正确"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_session_not_found(self, db_session):
|
|
"""测试获取不存在的会话"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
result = await svc.get_session("nonexistent_id")
|
|
|
|
assert result is None, "不存在的会话应返回 None"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sessions(self, db_session):
|
|
"""测试列出会话"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
# 创建多个会话
|
|
for i in range(3):
|
|
await svc.create_session(
|
|
conversation_id=f"conv_{i}",
|
|
employee_id="emp_003",
|
|
description=f"测试会话{i}",
|
|
mode="real_exec",
|
|
)
|
|
|
|
sessions = await svc.list_sessions(employee_id="emp_003")
|
|
|
|
assert len(sessions) == 3, "应返回3个会话"
|
|
# 验证按时间倒序
|
|
assert sessions[0].created_at >= sessions[-1].created_at, "应按时间倒序"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_sessions_with_status_filter(self, db_session):
|
|
"""测试按状态过滤"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
# 创建会话
|
|
s1 = await svc.create_session(
|
|
conversation_id="conv_status_1",
|
|
employee_id="emp_status",
|
|
description="会话1",
|
|
mode="real_exec",
|
|
)
|
|
s2 = await svc.create_session(
|
|
conversation_id="conv_status_2",
|
|
employee_id="emp_status",
|
|
description="会话2",
|
|
mode="real_exec",
|
|
)
|
|
|
|
# 手动修改状态
|
|
s2.status = "closed"
|
|
await db_session.flush()
|
|
|
|
# 按 created 状态过滤
|
|
created_sessions = await svc.list_sessions(
|
|
employee_id="emp_status",
|
|
status="created"
|
|
)
|
|
|
|
assert len(created_sessions) == 1, "应返回1个 created 状态的会话"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_takeover(self, db_session):
|
|
"""测试转人工接管"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
session = await svc.create_session(
|
|
conversation_id="conv_takeover",
|
|
employee_id="emp_takeover",
|
|
description="测试转人工",
|
|
mode="real_exec",
|
|
)
|
|
|
|
# 转人工接管
|
|
result = await svc.takeover(session.id, agent_id="agent_001", note="测试转接")
|
|
|
|
assert result.status == "handoff", "状态应为 handoff"
|
|
assert result.agent_id == "agent_001", "接管坐席ID应正确"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_takeover_not_found(self, db_session):
|
|
"""测试转接不存在的会话"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
with pytest.raises(AutomationException) as exc:
|
|
await svc.takeover("nonexistent_id", "agent_001")
|
|
|
|
assert exc.value.code == AutomationErrorCode.SESSION_NOT_FOUND
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_feedback_satisfied(self, db_session):
|
|
"""测试员工满意反馈"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
session = await svc.create_session(
|
|
conversation_id="conv_feedback",
|
|
employee_id="emp_feedback",
|
|
description="测试反馈",
|
|
mode="real_exec",
|
|
)
|
|
session.status = "resolved" # 先设为 resolved
|
|
await db_session.flush()
|
|
|
|
result = await svc.resolve_feedback(session.id, satisfied=True, note="满意")
|
|
|
|
assert result.status == "closed", "状态应为 closed"
|
|
assert result.closed_by == "emp_feedback", "关单人应为员工"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_feedback_unsatisfied(self, db_session):
|
|
"""测试员工不满意反馈"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
session = await svc.create_session(
|
|
conversation_id="conv_feedback_2",
|
|
employee_id="emp_feedback_2",
|
|
description="测试反馈2",
|
|
mode="real_exec",
|
|
)
|
|
session.status = "resolved"
|
|
await db_session.flush()
|
|
|
|
result = await svc.resolve_feedback(session.id, satisfied=False, note="不满意")
|
|
|
|
assert result.status == "handoff", "状态应为 handoff"
|
|
assert result.closed_by == "employee(reject)", "关单人应为员工拒绝"
|
|
|
|
|
|
# =============================================================================
|
|
# 测试 ActionExecutor - 执行引擎
|
|
# =============================================================================
|
|
class TestActionExecutor:
|
|
"""动作执行引擎测试"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_session_and_actions(self, db_session):
|
|
"""测试加载会话和动作"""
|
|
# 创建会话
|
|
session = AutoSession(
|
|
conversation_id="exec_conv_001",
|
|
employee_id="emp_exec_001",
|
|
title="执行测试",
|
|
status="running",
|
|
)
|
|
db_session.add(session)
|
|
await db_session.flush()
|
|
|
|
# 创建动作
|
|
actions = []
|
|
for i in range(3):
|
|
action = AutoAction(
|
|
session_id=session.id,
|
|
action_index=i,
|
|
action_type="software_install_guide",
|
|
risk_level="low",
|
|
title=f"动作{i}",
|
|
description=f"描述{i}",
|
|
status="pending",
|
|
)
|
|
db_session.add(action)
|
|
actions.append(action)
|
|
await db_session.flush()
|
|
|
|
# 测试加载
|
|
executor = ActionExecutor(db_session)
|
|
loaded_session, loaded_actions = await executor._load(session.id)
|
|
|
|
assert loaded_session is not None, "应加载到会话"
|
|
assert len(loaded_actions) == 3, "应加载3个动作"
|
|
# 验证排序
|
|
assert loaded_actions[0].action_index == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_with_terminal_state(self, db_session):
|
|
"""测试终态会话跳过执行"""
|
|
session = AutoSession(
|
|
conversation_id="exec_conv_002",
|
|
employee_id="emp_exec_002",
|
|
title="终态测试",
|
|
status="closed", # 终态
|
|
)
|
|
db_session.add(session)
|
|
await db_session.flush()
|
|
|
|
executor = ActionExecutor(db_session)
|
|
await executor.run(session.id)
|
|
|
|
# 不应抛出异常,应直接返回
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_clients(self, db_session):
|
|
"""测试构建外部客户端"""
|
|
executor = ActionExecutor(db_session)
|
|
clients = await executor._build_clients()
|
|
|
|
assert isinstance(clients, dict), "应返回客户端字典"
|
|
# 验证包含必要的客户端键
|
|
assert "huorong" in clients
|
|
assert "lianruan" in clients
|
|
assert "ehr" in clients
|
|
assert "dify" in clients
|
|
|
|
|
|
# =============================================================================
|
|
# 测试场景配置管理
|
|
# =============================================================================
|
|
class TestScenarioConfig:
|
|
"""场景配置管理测试"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_scenario_configs(self, db_session):
|
|
"""测试列出场景配置"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
# 创建场景配置
|
|
config = ScenarioConfig(
|
|
scenario_key="test_scenario",
|
|
name="测试场景",
|
|
description="测试用场景",
|
|
enabled=True,
|
|
actions=[],
|
|
)
|
|
db_session.add(config)
|
|
await db_session.flush()
|
|
|
|
configs = await svc.list_scenario_configs()
|
|
|
|
assert len(configs) >= 1, "应返回场景配置"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_scenario_config_create(self, db_session):
|
|
"""测试创建场景配置"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
data = {
|
|
"name": "新场景",
|
|
"description": "新场景描述",
|
|
"enabled": True,
|
|
"actions": [
|
|
{
|
|
"action_type": "software_install_guide",
|
|
"adapter": "internal",
|
|
"risk_level": "low",
|
|
"title": "安装软件",
|
|
"description": "安装软件",
|
|
}
|
|
],
|
|
"approval_strategy": {"read": "auto", "low": "auto", "high": "approval"},
|
|
}
|
|
|
|
config = await svc.upsert_scenario_config("new_scene", data, operator="test_admin")
|
|
|
|
assert config.scenario_key == "new_scene", "场景键应正确"
|
|
assert config.name == "新场景", "名称应正确"
|
|
assert config.enabled is True, "应启用"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upsert_scenario_config_update(self, db_session):
|
|
"""测试更新场景配置"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
# 先创建
|
|
await svc.upsert_scenario_config(
|
|
"update_scene",
|
|
{"name": "旧名称", "enabled": True},
|
|
operator="admin",
|
|
)
|
|
|
|
# 再更新
|
|
updated = await svc.upsert_scenario_config(
|
|
"update_scene",
|
|
{"name": "新名称"},
|
|
operator="admin",
|
|
)
|
|
|
|
assert updated.name == "新名称", "名称应更新"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_rule_versions(self, db_session):
|
|
"""测试列出规则版本"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
# 创建场景配置和版本
|
|
await svc.upsert_scenario_config(
|
|
"version_test",
|
|
{"name": "版本测试", "actions": []},
|
|
operator="admin",
|
|
)
|
|
|
|
versions = await svc.list_rule_versions("version_test")
|
|
|
|
assert len(versions) >= 1, "应返回规则版本"
|
|
|
|
|
|
# =============================================================================
|
|
# 测试指标汇总
|
|
# =============================================================================
|
|
class TestMetrics:
|
|
"""指标汇总测试"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metrics(self, db_session):
|
|
"""测试指标计算"""
|
|
svc = AutoSessionService(db_session)
|
|
|
|
# 创建不同状态的会话
|
|
for status in ["resolved", "resolved", "handoff", "error"]:
|
|
session = AutoSession(
|
|
conversation_id=f"metric_conv_{status}",
|
|
employee_id="emp_metric",
|
|
title=f"指标测试_{status}",
|
|
status=status,
|
|
)
|
|
db_session.add(session)
|
|
await db_session.flush()
|
|
|
|
metrics = await svc.metrics()
|
|
|
|
assert "total_sessions" in metrics, "应包含总会话数"
|
|
assert "resolved_sessions" in metrics, "应包含已解决数"
|
|
assert "handoff_sessions" in metrics, "应包含转人工数"
|
|
assert "error_sessions" in metrics, "应包含错误数"
|
|
assert metrics["total_sessions"] == 4, "应有4个会话"
|
|
assert metrics["resolved_sessions"] == 2, "应有2个resolved"
|