Files
wecom_it_smart_desk/backend/tests/test_automation_services.py
Simon 5e53146a9a test(backend): unit/integration tests for automation, otp, neo4j, contract
新增自动化审批状态机/执行器/意图路由/会话管理、OTP 绑定流程、neo4j 客户端、响应契约、置信度门禁、环境门控、Tier1 API 等测试。
2026-07-09 11:49:50 +08:00

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"