feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (9项) == - 代办事项真实数据源集成 (企微审批API 8bug修复链) - H5/坐席端 Logo样式统一+绿色背景 - 视频引导页修复 (localStorage key v2) - 坐席端 v9 Vue版本修复 (ElMessage._context) - 截图按钮 v10 修复 (getDisplayMedia user gesture) - 扫码样式恢复+H5扫码登录跳转修复 - H5截图快捷键提示 == 代码完成待部署 (3项) == - 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查) - 会议室预定-小鱼易联终端 (40文件, 40/40测试通过) - IT资产升级审批推送 (asset_service.py) == 需求文档 (2项) == - 坐席端AI辅助消息框-PRD (4项新功能确认) - 坐席端布局优化建议 v2.0 (7天计划) == 新增文档 == - 日报-2026-07-11.md - 知识迭代Bug修复报告-20260711.md - 会议室预定-部署指南.md - CHANGELOG.md 更新 == 测试 == - test_todo_integration.py: 40/40 - test_meetingroom.py: 40/40 - test_bugfix_ki_suggestions.py: 21/21
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# 自动化测试包初始化
|
||||
@@ -0,0 +1,62 @@
|
||||
# =============================================================================
|
||||
# 复杂场景重构 — 自动化测试局部 conftest
|
||||
# =============================================================================
|
||||
# 说明:扩展全局 MockRedis,增加 sadd/srem 方法(session_manager 和
|
||||
# timeout_cleaner 中使用了 Redis SET 操作)。
|
||||
# =============================================================================
|
||||
|
||||
from typing import Dict, Set
|
||||
from tests.conftest import MockRedis
|
||||
|
||||
|
||||
class ExtendedMockRedis(MockRedis):
|
||||
"""扩展 MockRedis,支持 sadd/srem/smembers 操作。
|
||||
|
||||
session_manager.pause_session 使用 redis.sadd() 向暂停会话集合添加成员,
|
||||
resume_session / agent_close / timeout_cleaner 使用 redis.srem() 移除成员。
|
||||
基础 MockRedis 不含这些方法,此处扩展。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._sets: Dict[str, Set[str]] = {}
|
||||
|
||||
async def sadd(self, name: str, *values) -> int:
|
||||
"""模拟 Redis SADD 命令。"""
|
||||
s = self._sets.setdefault(name, set())
|
||||
count = 0
|
||||
for v in values:
|
||||
v_str = v.decode("utf-8") if isinstance(v, bytes) else str(v)
|
||||
if v_str not in s:
|
||||
s.add(v_str)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def srem(self, name: str, *values) -> int:
|
||||
"""模拟 Redis SREM 命令。"""
|
||||
s = self._sets.get(name, set())
|
||||
count = 0
|
||||
for v in values:
|
||||
v_str = v.decode("utf-8") if isinstance(v, bytes) else str(v)
|
||||
if v_str in s:
|
||||
s.discard(v_str)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def smembers(self, name: str) -> Set[str]:
|
||||
"""模拟 Redis SMEMBERS 命令。"""
|
||||
return set(self._sets.get(name, set()))
|
||||
|
||||
def reset(self) -> None:
|
||||
"""重置所有数据。"""
|
||||
super().reset()
|
||||
self._sets.clear()
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
"""覆盖全局 mock_redis fixture,返回支持 sadd/srem 的扩展版本。"""
|
||||
return ExtendedMockRedis()
|
||||
@@ -0,0 +1,381 @@
|
||||
# =============================================================================
|
||||
# 复杂场景重构 — 全局意图识别单元测试
|
||||
# =============================================================================
|
||||
# 测试范围:intent_router.py — detect_global_intent / detect
|
||||
# - detect_global_intent 识别 PAUSE / RESUME_TASK / CORRECT / SUPPLEMENT
|
||||
# - detect 全局意图优先于场景意图
|
||||
# - 关键词兜底(Dify 未配置时)
|
||||
# =============================================================================
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.constants import (
|
||||
GLOBAL_INTENT_CORRECT,
|
||||
GLOBAL_INTENT_PAUSE,
|
||||
GLOBAL_INTENT_RESUME_TASK,
|
||||
GLOBAL_INTENT_SUPPLEMENT,
|
||||
)
|
||||
from app.services.automation.intent_router import IntentRouter
|
||||
|
||||
|
||||
class TestDetectGlobalIntent:
|
||||
"""全局意图识别测试用例"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_pause_intent(self):
|
||||
"""测试识别 PAUSE 意图"""
|
||||
# Arrange — Dify 未配置,走关键词兜底
|
||||
router = IntentRouter()
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
# Act
|
||||
result = await router.detect_global_intent("我先去开会了,等会继续")
|
||||
|
||||
# Assert
|
||||
assert result["global_intent"] == GLOBAL_INTENT_PAUSE
|
||||
assert result["confidence"] > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_resume_intent(self):
|
||||
"""测试识别 RESUME_TASK 意图"""
|
||||
router = IntentRouter()
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
result = await router.detect_global_intent("继续刚才的任务")
|
||||
|
||||
assert result["global_intent"] == GLOBAL_INTENT_RESUME_TASK
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_correct_intent(self):
|
||||
"""测试识别 CORRECT 意图"""
|
||||
router = IntentRouter()
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
result = await router.detect_global_intent("刚才说错了,用户名应该是 lisi")
|
||||
|
||||
assert result["global_intent"] == GLOBAL_INTENT_CORRECT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_supplement_intent(self):
|
||||
"""测试识别 SUPPLEMENT 意图"""
|
||||
router = IntentRouter()
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
result = await router.detect_global_intent("再补充一下,是财务部的电脑")
|
||||
|
||||
assert result["global_intent"] == GLOBAL_INTENT_SUPPLEMENT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_no_global_intent(self):
|
||||
"""测试无全局意图时返回 None"""
|
||||
router = IntentRouter()
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
result = await router.detect_global_intent("我的电脑中毒了,帮我查杀")
|
||||
|
||||
assert result["global_intent"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_pause_keyword_variants(self):
|
||||
"""测试 PAUSE 意图关键词变体"""
|
||||
router = IntentRouter()
|
||||
test_cases = [
|
||||
"先去开会",
|
||||
"等会继续",
|
||||
"先处理别的",
|
||||
"暂停",
|
||||
"我去忙一下",
|
||||
"晚点再说",
|
||||
]
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
for text in test_cases:
|
||||
result = await router.detect_global_intent(text)
|
||||
assert result["global_intent"] == GLOBAL_INTENT_PAUSE, (
|
||||
f"文本「{text}」应识别为 PAUSE"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_resume_keyword_variants(self):
|
||||
"""测试 RESUME_TASK 意图关键词变体"""
|
||||
router = IntentRouter()
|
||||
test_cases = [
|
||||
"继续",
|
||||
"继续刚才",
|
||||
"好了继续吧",
|
||||
"接着来",
|
||||
"恢复",
|
||||
"回来了",
|
||||
]
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
for text in test_cases:
|
||||
result = await router.detect_global_intent(text)
|
||||
assert result["global_intent"] == GLOBAL_INTENT_RESUME_TASK, (
|
||||
f"文本「{text}」应识别为 RESUME_TASK"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_correct_keyword_variants(self):
|
||||
"""测试 CORRECT 意图关键词变体"""
|
||||
router = IntentRouter()
|
||||
test_cases = [
|
||||
"刚才说错了",
|
||||
"应该是 lisi",
|
||||
"更正一下",
|
||||
"说错了",
|
||||
]
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
for text in test_cases:
|
||||
result = await router.detect_global_intent(text)
|
||||
assert result["global_intent"] == GLOBAL_INTENT_CORRECT, (
|
||||
f"文本「{text}」应识别为 CORRECT"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_supplement_keyword_variants(self):
|
||||
"""测试 SUPPLEMENT 意图关键词变体"""
|
||||
router = IntentRouter()
|
||||
test_cases = [
|
||||
"再补充一下",
|
||||
"顺便说一下",
|
||||
"还有个事",
|
||||
"对了补充",
|
||||
"另外",
|
||||
]
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
for text in test_cases:
|
||||
result = await router.detect_global_intent(text)
|
||||
assert result["global_intent"] == GLOBAL_INTENT_SUPPLEMENT, (
|
||||
f"文本「{text}」应识别为 SUPPLEMENT"
|
||||
)
|
||||
|
||||
|
||||
class TestDetectWithGlobalIntentPriority:
|
||||
"""全局意图优先级测试用例"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_intent_priority_over_scenario(self):
|
||||
"""测试全局意图优先于场景意图 — 同时包含暂停关键词和场景关键词"""
|
||||
# Arrange — "暂停一下,帮我重置密码" 同时包含 "暂停"(全局)和 "重置密码"(场景)
|
||||
router = IntentRouter()
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
# Act
|
||||
result = await router.detect("暂停一下,帮我重置密码", "emp_001")
|
||||
|
||||
# Assert — 全局意图优先,返回 pause 而非 password_reset
|
||||
assert result["global_intent"] == GLOBAL_INTENT_PAUSE
|
||||
# scenario_key 可能为 None(全局意图命中时跳过场景识别)
|
||||
assert result.get("scenario_key") is None or result.get("scenario_key") != "password_reset"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_no_global_intent_falls_to_scenario(self):
|
||||
"""测试无全局意图时走场景识别"""
|
||||
# Arrange
|
||||
router = IntentRouter()
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
# Act — "帮我重置密码" 不含全局意图关键词,应走场景识别
|
||||
result = await router.detect("帮我重置密码", "emp_001")
|
||||
|
||||
# Assert — 走场景识别,global_intent 为 None
|
||||
assert result["global_intent"] is None
|
||||
assert result["scenario_key"] == "password_reset"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_resume_priority_over_scenario(self):
|
||||
"""测试恢复意图优先 — "继续帮我重置密码" 应识别为 RESUME_TASK"""
|
||||
router = IntentRouter()
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
result = await router.detect("继续帮我重置密码", "emp_001")
|
||||
|
||||
assert result["global_intent"] == GLOBAL_INTENT_RESUME_TASK
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_correct_priority_over_scenario(self):
|
||||
"""测试更正意图优先 — "说错了,不是密码重置" 应识别为 CORRECT"""
|
||||
router = IntentRouter()
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
result = await router.detect("说错了,不是密码重置", "emp_001")
|
||||
|
||||
assert result["global_intent"] == GLOBAL_INTENT_CORRECT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_supplement_priority_over_scenario(self):
|
||||
"""测试补充意图优先 — "另外帮我安装个软件" 应识别为 SUPPLEMENT"""
|
||||
router = IntentRouter()
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
result = await router.detect("另外帮我安装个软件", "emp_001")
|
||||
|
||||
assert result["global_intent"] == GLOBAL_INTENT_SUPPLEMENT
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_returns_all_fields(self):
|
||||
"""测试 detect 返回结果包含所有必要字段"""
|
||||
router = IntentRouter()
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=None,
|
||||
):
|
||||
result = await router.detect("今天天气真好", "emp_001")
|
||||
|
||||
# 所有字段都应存在
|
||||
assert "global_intent" in result
|
||||
assert "scenario_key" in result
|
||||
assert "confidence" in result
|
||||
assert "corrected_field" in result
|
||||
assert "old_value" in result
|
||||
assert "new_value" in result
|
||||
assert "supplement_field" in result
|
||||
assert "supplement_value" in result
|
||||
assert "raw" in result
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestKeywordFallbackGlobal:
|
||||
"""全局意图关键词兜底测试用例"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_fallback_confidence(self):
|
||||
"""测试关键词兜底置信度为 0.6"""
|
||||
router = IntentRouter()
|
||||
result = router._keyword_fallback_global("先去开会")
|
||||
assert result["confidence"] == 0.6
|
||||
assert result["error"] == "fallback"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_fallback_no_match(self):
|
||||
"""测试关键词兜底无匹配时返回 None"""
|
||||
router = IntentRouter()
|
||||
result = router._keyword_fallback_global("今天天气真好")
|
||||
assert result["global_intent"] is None
|
||||
assert result["confidence"] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_fallback_empty_text(self):
|
||||
"""测试空文本兜底返回 None"""
|
||||
router = IntentRouter()
|
||||
result = router._keyword_fallback_global("")
|
||||
assert result["global_intent"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_fallback_case_insensitive(self):
|
||||
"""测试关键词兜底大小写不敏感"""
|
||||
router = IntentRouter()
|
||||
# 中文关键词不区分大小写,但测试确保不会因大小写报错
|
||||
result = router._keyword_fallback_global("暂停")
|
||||
assert result["global_intent"] == GLOBAL_INTENT_PAUSE
|
||||
|
||||
|
||||
class TestDetectGlobalIntentWithDify:
|
||||
"""Dify 客户端可用时的全局意图识别测试"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_global_intent_with_dify_success(self):
|
||||
"""测试 Dify 返回全局意图时正确解析"""
|
||||
# Arrange
|
||||
router = IntentRouter()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.detect_intent = AsyncMock(return_value={
|
||||
"global_intent": GLOBAL_INTENT_PAUSE,
|
||||
"scenario_key": None,
|
||||
"confidence": 0.95,
|
||||
"corrected_field": None,
|
||||
"old_value": None,
|
||||
"new_value": None,
|
||||
"supplement_field": None,
|
||||
"supplement_value": None,
|
||||
"raw": "pause",
|
||||
"error": "",
|
||||
})
|
||||
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
# Act
|
||||
result = await router.detect_global_intent("我先去开会")
|
||||
|
||||
# Assert
|
||||
assert result["global_intent"] == GLOBAL_INTENT_PAUSE
|
||||
assert result["confidence"] == 0.95
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_global_intent_dify_exception_fallback(self):
|
||||
"""测试 Dify 异常时走关键词兜底"""
|
||||
router = IntentRouter()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.detect_intent = AsyncMock(side_effect=Exception("Dify error"))
|
||||
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await router.detect_global_intent("先去开会")
|
||||
|
||||
# 应走关键词兜底
|
||||
assert result["global_intent"] == GLOBAL_INTENT_PAUSE
|
||||
assert result["confidence"] == 0.6
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detect_with_dify_returns_global_intent(self):
|
||||
"""测试 detect 方法通过 Dify 返回全局意图"""
|
||||
router = IntentRouter()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.detect_intent = AsyncMock(return_value={
|
||||
"global_intent": GLOBAL_INTENT_CORRECT,
|
||||
"scenario_key": None,
|
||||
"confidence": 0.9,
|
||||
"corrected_field": "用户名",
|
||||
"old_value": "zhangsan",
|
||||
"new_value": "lisi",
|
||||
"supplement_field": None,
|
||||
"supplement_value": None,
|
||||
"raw": "",
|
||||
"error": "",
|
||||
})
|
||||
|
||||
with patch(
|
||||
"app.services.automation.intent_router.build_dify_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await router.detect("刚才说错了,用户名是 lisi", "emp_001")
|
||||
|
||||
assert result["global_intent"] == GLOBAL_INTENT_CORRECT
|
||||
assert result["corrected_field"] == "用户名"
|
||||
assert result["new_value"] == "lisi"
|
||||
@@ -0,0 +1,504 @@
|
||||
# =============================================================================
|
||||
# 复杂场景重构 — 信息项管理服务单元测试
|
||||
# =============================================================================
|
||||
# 测试范围:information_item_service.py
|
||||
# - create_item / get_items / get_item
|
||||
# - correct_value(更正:版本+1,旧值存入 history)
|
||||
# - correct_value 锁定项不可更正(4015)
|
||||
# - supplement_value 增量追加 / 非增量覆盖 / 不存在则创建
|
||||
# - lock_items_for_action 动作执行后锁定固定信息项
|
||||
# - check_downstream_impact 下游影响检测
|
||||
# - get_pending_required_items 未填写必需项
|
||||
# =============================================================================
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.constants import (
|
||||
AutomationErrorCode,
|
||||
INFO_MODIFIER_FIXED,
|
||||
INFO_MODIFIER_INCREMENTAL,
|
||||
INFO_MODIFIER_REQUIRED,
|
||||
)
|
||||
from app.models.automation import AutoAction, InformationItem
|
||||
from app.services.automation.exception_handler import AutomationException
|
||||
from app.services.automation.information_item_service import InformationItemService
|
||||
|
||||
|
||||
class TestInformationItemService:
|
||||
"""信息项管理服务测试用例"""
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 创建与查询
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_item(self, db_session):
|
||||
"""测试创建信息项"""
|
||||
# Arrange
|
||||
svc = InformationItemService(db_session)
|
||||
|
||||
# Act
|
||||
item = await svc.create_item(
|
||||
session_id="sess_001",
|
||||
name="用户名",
|
||||
value="zhangsan",
|
||||
modifiers=["明确", "必需"],
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert item.id is not None
|
||||
assert item.session_id == "sess_001"
|
||||
assert item.name == "用户名"
|
||||
assert item.value == "zhangsan"
|
||||
assert item.modifiers == ["明确", "必需"]
|
||||
assert item.is_filled is True
|
||||
assert item.is_locked is False
|
||||
assert item.version == 1
|
||||
assert item.update_history == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_item_empty_value(self, db_session):
|
||||
"""测试创建信息项 — 空值时 is_filled 为 False"""
|
||||
svc = InformationItemService(db_session)
|
||||
item = await svc.create_item("sess_001", "备注", "", ["增量"])
|
||||
assert item.is_filled is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_items(self, db_session):
|
||||
"""测试获取会话下所有信息项"""
|
||||
# Arrange
|
||||
svc = InformationItemService(db_session)
|
||||
await svc.create_item("sess_001", "用户名", "zhangsan", ["必需"])
|
||||
await svc.create_item("sess_001", "部门", "技术部", [])
|
||||
await svc.create_item("sess_002", "用户名", "lisi", ["必需"])
|
||||
|
||||
# Act
|
||||
items = await svc.get_items("sess_001")
|
||||
|
||||
# Assert
|
||||
assert len(items) == 2
|
||||
names = {i.name for i in items}
|
||||
assert names == {"用户名", "部门"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_item(self, db_session):
|
||||
"""测试按名称获取单个信息项"""
|
||||
svc = InformationItemService(db_session)
|
||||
await svc.create_item("sess_001", "用户名", "zhangsan", ["必需"])
|
||||
|
||||
item = await svc.get_item("sess_001", "用户名")
|
||||
assert item is not None
|
||||
assert item.value == "zhangsan"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_item_not_found(self, db_session):
|
||||
"""测试获取不存在的信息项返回 None"""
|
||||
svc = InformationItemService(db_session)
|
||||
item = await svc.get_item("sess_001", "不存在")
|
||||
assert item is None
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 更正(CORRECT)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_correct_value(self, db_session):
|
||||
"""测试更正信息项 — 版本+1,旧值存入 history"""
|
||||
# Arrange
|
||||
svc = InformationItemService(db_session)
|
||||
await svc.create_item("sess_001", "用户名", "zhangsan", ["明确", "必需"])
|
||||
|
||||
# Act
|
||||
item = await svc.correct_value("sess_001", "用户名", "lisi")
|
||||
|
||||
# Assert
|
||||
assert item.value == "lisi"
|
||||
assert item.version == 2
|
||||
assert item.is_filled is True
|
||||
# 验证变更历史
|
||||
assert len(item.update_history) == 1
|
||||
history = item.update_history[0]
|
||||
assert history["old_value"] == "zhangsan"
|
||||
assert history["new_value"] == "lisi"
|
||||
assert history["action"] == "correct"
|
||||
assert history["version"] == 1 # 变更前的版本号
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_correct_value_with_explicit_old(self, db_session):
|
||||
"""测试更正信息项 — 显式传入旧值"""
|
||||
svc = InformationItemService(db_session)
|
||||
await svc.create_item("sess_001", "用户名", "zhangsan", ["明确"])
|
||||
|
||||
item = await svc.correct_value(
|
||||
"sess_001", "用户名", "lisi", old_value="zhangsan"
|
||||
)
|
||||
assert item.value == "lisi"
|
||||
assert item.update_history[-1]["old_value"] == "zhangsan"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_correct_locked_item(self, db_session):
|
||||
"""测试锁定信息项不可更正 — 应抛出 4015 错误码"""
|
||||
# Arrange
|
||||
svc = InformationItemService(db_session)
|
||||
item = await svc.create_item(
|
||||
"sess_001", "终端ID", "PC-001", [INFO_MODIFIER_FIXED, "必需"]
|
||||
)
|
||||
item.is_locked = True
|
||||
await db_session.flush()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(AutomationException) as exc_info:
|
||||
await svc.correct_value("sess_001", "终端ID", "PC-002")
|
||||
|
||||
assert exc_info.value.code == AutomationErrorCode.INFO_ITEM_LOCKED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_correct_nonexistent_item(self, db_session):
|
||||
"""测试更正不存在的信息项 — 自动创建"""
|
||||
svc = InformationItemService(db_session)
|
||||
|
||||
item = await svc.correct_value("sess_001", "新字段", "新值")
|
||||
assert item is not None
|
||||
assert item.value == "新值"
|
||||
assert item.version == 1
|
||||
assert item.is_filled is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_correct_value_multiple_times(self, db_session):
|
||||
"""测试多次更正 — 版本递增,历史累积"""
|
||||
svc = InformationItemService(db_session)
|
||||
await svc.create_item("sess_001", "用户名", "val1", [])
|
||||
|
||||
await svc.correct_value("sess_001", "用户名", "val2")
|
||||
item = await svc.correct_value("sess_001", "用户名", "val3")
|
||||
|
||||
assert item.value == "val3"
|
||||
assert item.version == 3
|
||||
assert len(item.update_history) == 2
|
||||
assert item.update_history[0]["old_value"] == "val1"
|
||||
assert item.update_history[0]["new_value"] == "val2"
|
||||
assert item.update_history[1]["old_value"] == "val2"
|
||||
assert item.update_history[1]["new_value"] == "val3"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 补充(SUPPLEMENT)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supplement_incremental(self, db_session):
|
||||
"""测试增量补充 — 追加值,分号分隔"""
|
||||
# Arrange
|
||||
svc = InformationItemService(db_session)
|
||||
await svc.create_item(
|
||||
"sess_001", "备注", "初始备注", [INFO_MODIFIER_INCREMENTAL]
|
||||
)
|
||||
|
||||
# Act
|
||||
item = await svc.supplement_value("sess_001", "备注", "补充内容")
|
||||
|
||||
# Assert
|
||||
assert item.value == "初始备注; 补充内容"
|
||||
assert item.version == 2
|
||||
assert item.is_filled is True
|
||||
assert len(item.update_history) == 1
|
||||
assert item.update_history[0]["action"] == "supplement"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supplement_non_incremental(self, db_session):
|
||||
"""测试非增量补充 — 覆盖值"""
|
||||
# Arrange
|
||||
svc = InformationItemService(db_session)
|
||||
await svc.create_item("sess_001", "用户名", "zhangsan", ["明确"])
|
||||
|
||||
# Act
|
||||
item = await svc.supplement_value("sess_001", "用户名", "lisi")
|
||||
|
||||
# Assert
|
||||
assert item.value == "lisi"
|
||||
assert item.version == 2
|
||||
assert len(item.update_history) == 1
|
||||
assert item.update_history[0]["old_value"] == "zhangsan"
|
||||
assert item.update_history[0]["new_value"] == "lisi"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supplement_create_new(self, db_session):
|
||||
"""测试补充不存在的信息项 — 自动创建,默认增量修饰符"""
|
||||
svc = InformationItemService(db_session)
|
||||
|
||||
item = await svc.supplement_value("sess_001", "新备注", "第一条")
|
||||
assert item is not None
|
||||
assert item.value == "第一条"
|
||||
assert item.version == 1
|
||||
assert INFO_MODIFIER_INCREMENTAL in (item.modifiers or [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supplement_incremental_empty_value(self, db_session):
|
||||
"""测试增量补充 — 原值为空时直接设为新值"""
|
||||
svc = InformationItemService(db_session)
|
||||
await svc.create_item(
|
||||
"sess_001", "备注", "", [INFO_MODIFIER_INCREMENTAL]
|
||||
)
|
||||
|
||||
item = await svc.supplement_value("sess_001", "备注", "第一条")
|
||||
assert item.value == "第一条"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supplement_non_incremental_locked(self, db_session):
|
||||
"""测试非增量补充锁定项 — 应抛出 4015"""
|
||||
svc = InformationItemService(db_session)
|
||||
item = await svc.create_item(
|
||||
"sess_001", "终端ID", "PC-001", [INFO_MODIFIER_FIXED]
|
||||
)
|
||||
item.is_locked = True
|
||||
await db_session.flush()
|
||||
|
||||
with pytest.raises(AutomationException) as exc_info:
|
||||
await svc.supplement_value("sess_001", "终端ID", "PC-002")
|
||||
|
||||
assert exc_info.value.code == AutomationErrorCode.INFO_ITEM_LOCKED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supplement_incremental_multiple_times(self, db_session):
|
||||
"""测试增量补充多次 — 持续追加"""
|
||||
svc = InformationItemService(db_session)
|
||||
await svc.create_item(
|
||||
"sess_001", "备注", "v1", [INFO_MODIFIER_INCREMENTAL]
|
||||
)
|
||||
|
||||
await svc.supplement_value("sess_001", "备注", "v2")
|
||||
item = await svc.supplement_value("sess_001", "备注", "v3")
|
||||
|
||||
assert item.value == "v1; v2; v3"
|
||||
assert item.version == 3
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 锁定(动作执行后)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_items_for_action(self, db_session):
|
||||
"""测试动作执行后锁定固定信息项"""
|
||||
# Arrange
|
||||
svc = InformationItemService(db_session)
|
||||
# 创建固定信息项(应被锁定)
|
||||
await svc.create_item(
|
||||
"sess_001", "终端ID", "PC-001", [INFO_MODIFIER_FIXED, "必需"]
|
||||
)
|
||||
# 创建非固定信息项(不应被锁定)
|
||||
await svc.create_item("sess_001", "备注", "测试", [INFO_MODIFIER_INCREMENTAL])
|
||||
|
||||
# 创建动作,payload 中引用 "终端ID"
|
||||
action = AutoAction(
|
||||
session_id="sess_001",
|
||||
action_index=0,
|
||||
action_type="terminal_locate",
|
||||
status="success",
|
||||
title="定位终端",
|
||||
payload={"终端ID": "PC-001", "其他参数": "xxx"},
|
||||
)
|
||||
db_session.add(action)
|
||||
await db_session.flush()
|
||||
|
||||
# Act
|
||||
await svc.lock_items_for_action("sess_001", action.id)
|
||||
|
||||
# Assert
|
||||
terminal_item = await svc.get_item("sess_001", "终端ID")
|
||||
assert terminal_item.is_locked is True
|
||||
|
||||
remark_item = await svc.get_item("sess_001", "备注")
|
||||
assert remark_item.is_locked is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_items_already_locked(self, db_session):
|
||||
"""测试已锁定的信息项不会被重复锁定"""
|
||||
svc = InformationItemService(db_session)
|
||||
item = await svc.create_item(
|
||||
"sess_001", "终端ID", "PC-001", [INFO_MODIFIER_FIXED]
|
||||
)
|
||||
item.is_locked = True
|
||||
await db_session.flush()
|
||||
|
||||
action = AutoAction(
|
||||
session_id="sess_001",
|
||||
action_index=0,
|
||||
action_type="test",
|
||||
status="success",
|
||||
title="测试",
|
||||
payload={"终端ID": "PC-001"},
|
||||
)
|
||||
db_session.add(action)
|
||||
await db_session.flush()
|
||||
|
||||
# 不应抛出异常
|
||||
await svc.lock_items_for_action("sess_001", action.id)
|
||||
|
||||
terminal_item = await svc.get_item("sess_001", "终端ID")
|
||||
assert terminal_item.is_locked is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_items_action_not_found(self, db_session):
|
||||
"""测试动作不存在时不抛异常"""
|
||||
svc = InformationItemService(db_session)
|
||||
# 不应抛出异常
|
||||
await svc.lock_items_for_action("sess_001", "nonexistent_action")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lock_items_empty_payload(self, db_session):
|
||||
"""测试动作 payload 为空时不锁定任何项"""
|
||||
svc = InformationItemService(db_session)
|
||||
await svc.create_item(
|
||||
"sess_001", "终端ID", "PC-001", [INFO_MODIFIER_FIXED]
|
||||
)
|
||||
|
||||
action = AutoAction(
|
||||
session_id="sess_001",
|
||||
action_index=0,
|
||||
action_type="test",
|
||||
status="success",
|
||||
title="测试",
|
||||
payload={},
|
||||
)
|
||||
db_session.add(action)
|
||||
await db_session.flush()
|
||||
|
||||
await svc.lock_items_for_action("sess_001", action.id)
|
||||
|
||||
item = await svc.get_item("sess_001", "终端ID")
|
||||
assert item.is_locked is False
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 下游影响检测
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_downstream_impact_has_impact(self, db_session):
|
||||
"""测试下游影响检测 — 待执行动作引用了被更正字段"""
|
||||
# Arrange
|
||||
svc = InformationItemService(db_session)
|
||||
|
||||
# 创建待执行动作,payload 引用 "用户名"
|
||||
action = AutoAction(
|
||||
session_id="sess_001",
|
||||
action_index=1,
|
||||
action_type="password_reset",
|
||||
status="pending",
|
||||
title="密码重置",
|
||||
payload={"用户名": "zhangsan", "其他": "xxx"},
|
||||
)
|
||||
db_session.add(action)
|
||||
await db_session.flush()
|
||||
|
||||
# Act
|
||||
has_impact = await svc.check_downstream_impact("sess_001", "用户名")
|
||||
|
||||
# Assert
|
||||
assert has_impact is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_downstream_impact_no_impact(self, db_session):
|
||||
"""测试下游影响检测 — 无待执行动作引用被更正字段"""
|
||||
# Arrange
|
||||
svc = InformationItemService(db_session)
|
||||
|
||||
action = AutoAction(
|
||||
session_id="sess_001",
|
||||
action_index=1,
|
||||
action_type="virus_scan",
|
||||
status="pending",
|
||||
title="病毒扫描",
|
||||
payload={"终端ID": "PC-001"},
|
||||
)
|
||||
db_session.add(action)
|
||||
await db_session.flush()
|
||||
|
||||
# Act — 检查 "用户名" 字段(payload 中不存在)
|
||||
has_impact = await svc.check_downstream_impact("sess_001", "用户名")
|
||||
|
||||
# Assert
|
||||
assert has_impact is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_downstream_impact_await_approval(self, db_session):
|
||||
"""测试下游影响检测 — await_approval 状态也算待执行"""
|
||||
svc = InformationItemService(db_session)
|
||||
|
||||
action = AutoAction(
|
||||
session_id="sess_001",
|
||||
action_index=0,
|
||||
action_type="high_risk_op",
|
||||
status="await_approval",
|
||||
title="高危操作",
|
||||
payload={"终端ID": "PC-001"},
|
||||
)
|
||||
db_session.add(action)
|
||||
await db_session.flush()
|
||||
|
||||
has_impact = await svc.check_downstream_impact("sess_001", "终端ID")
|
||||
assert has_impact is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_downstream_impact_completed_action(self, db_session):
|
||||
"""测试下游影响检测 — 已完成动作不算下游影响"""
|
||||
svc = InformationItemService(db_session)
|
||||
|
||||
action = AutoAction(
|
||||
session_id="sess_001",
|
||||
action_index=0,
|
||||
action_type="terminal_locate",
|
||||
status="success",
|
||||
title="定位完成",
|
||||
payload={"终端ID": "PC-001"},
|
||||
)
|
||||
db_session.add(action)
|
||||
await db_session.flush()
|
||||
|
||||
has_impact = await svc.check_downstream_impact("sess_001", "终端ID")
|
||||
assert has_impact is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_downstream_impact_no_actions(self, db_session):
|
||||
"""测试下游影响检测 — 无任何动作时返回 False"""
|
||||
svc = InformationItemService(db_session)
|
||||
has_impact = await svc.check_downstream_impact("sess_001", "用户名")
|
||||
assert has_impact is False
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 未填写必需信息项
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_required_items(self, db_session):
|
||||
"""测试获取未填写的必需信息项"""
|
||||
svc = InformationItemService(db_session)
|
||||
# 必需且未填写
|
||||
await svc.create_item("sess_001", "用户名", "", ["必需"])
|
||||
# 必需且已填写
|
||||
await svc.create_item("sess_001", "部门", "技术部", ["必需"])
|
||||
# 非必需
|
||||
await svc.create_item("sess_001", "备注", "", ["增量"])
|
||||
|
||||
pending = await svc.get_pending_required_items("sess_001")
|
||||
|
||||
assert len(pending) == 1
|
||||
assert "用户名" in pending
|
||||
assert "部门" not in pending
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_required_items_all_filled(self, db_session):
|
||||
"""测试所有必需项已填写时返回空列表"""
|
||||
svc = InformationItemService(db_session)
|
||||
await svc.create_item("sess_001", "用户名", "zhangsan", ["必需"])
|
||||
|
||||
pending = await svc.get_pending_required_items("sess_001")
|
||||
assert pending == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_required_items_no_required(self, db_session):
|
||||
"""测试无必需项时返回空列表"""
|
||||
svc = InformationItemService(db_session)
|
||||
await svc.create_item("sess_001", "备注", "xxx", ["增量"])
|
||||
|
||||
pending = await svc.get_pending_required_items("sess_001")
|
||||
assert pending == []
|
||||
@@ -0,0 +1,510 @@
|
||||
# =============================================================================
|
||||
# 复杂场景重构 — 会话暂停/恢复/坐席操作 单元测试
|
||||
# =============================================================================
|
||||
# 测试范围:session_manager.py 新增方法
|
||||
# - pause_session(running → paused,Redis 恢复点写入)
|
||||
# - pause_session 终态不可暂停(4013)
|
||||
# - resume_session(paused → running,Redis 恢复点清理)
|
||||
# - resume_session 多暂停会话返回列表(need_select=True)
|
||||
# - resume_session 超时关闭不可恢复(4014)
|
||||
# - agent_resume 坐席代恢复
|
||||
# - agent_close 坐席手动关闭
|
||||
# - list_paused_sessions 暂停会话列表
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.constants import (
|
||||
AutomationErrorCode,
|
||||
REDIS_KEY_PAUSED_SESSIONS,
|
||||
REDIS_KEY_RESUME_POINT,
|
||||
)
|
||||
from app.models.automation import AutoAction, AutoSession, InformationItem
|
||||
from app.services.automation.exception_handler import AutomationException
|
||||
from app.services.automation.session_manager import AutoSessionService
|
||||
|
||||
|
||||
class TestPauseSession:
|
||||
"""暂停会话测试用例"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_session(self, db_session, mock_redis):
|
||||
"""测试暂停会话 — running → paused,Redis 恢复点写入"""
|
||||
# Arrange
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
scenario_key="terminal_locate",
|
||||
title="终端定位",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
# Act
|
||||
result = await svc.pause_session(session.id, reason="用户主动暂停")
|
||||
|
||||
# Assert
|
||||
assert result.status == "paused"
|
||||
assert result.paused_at is not None
|
||||
|
||||
# 验证 Redis 恢复点已写入
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session.id)
|
||||
raw = await mock_redis.get(resume_key)
|
||||
assert raw is not None
|
||||
resume_point = json.loads(raw)
|
||||
assert resume_point["title"] == "终端定位"
|
||||
assert resume_point["scenario_key"] == "terminal_locate"
|
||||
|
||||
# 验证暂停会话集合
|
||||
paused_key = REDIS_KEY_PAUSED_SESSIONS.format(employee_id="emp_001")
|
||||
members = await mock_redis.smembers(paused_key)
|
||||
assert session.id in members
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_session_terminal_state(self, db_session, mock_redis):
|
||||
"""测试终态会话不可暂停 — 应返回 4013 错误码"""
|
||||
# Arrange
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
for terminal_status in ("closed", "handoff", "error"):
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title=f"测试_{terminal_status}",
|
||||
status=terminal_status,
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(AutomationException) as exc_info:
|
||||
await svc.pause_session(session.id)
|
||||
|
||||
assert exc_info.value.code == AutomationErrorCode.SESSION_NOT_PAUSABLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_session_not_found(self, db_session, mock_redis):
|
||||
"""测试暂停不存在的会话 — 应抛出 4005"""
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
|
||||
with pytest.raises(AutomationException) as exc_info:
|
||||
await svc.pause_session("nonexistent_id")
|
||||
|
||||
assert exc_info.value.code == AutomationErrorCode.SESSION_NOT_FOUND
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_session_with_info_items(self, db_session, mock_redis):
|
||||
"""测试暂停会话 — 恢复点快照包含信息项"""
|
||||
# Arrange
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="密码重置",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
# 创建信息项
|
||||
item = InformationItem(
|
||||
session_id=session.id,
|
||||
name="用户名",
|
||||
value="zhangsan",
|
||||
modifiers=["必需"],
|
||||
is_filled=True,
|
||||
version=1,
|
||||
update_history=[],
|
||||
)
|
||||
db_session.add(item)
|
||||
await db_session.flush()
|
||||
|
||||
# Act
|
||||
await svc.pause_session(session.id)
|
||||
|
||||
# Assert — 恢复点快照包含信息项
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session.id)
|
||||
raw = await mock_redis.get(resume_key)
|
||||
resume_point = json.loads(raw)
|
||||
assert len(resume_point["info_items"]) == 1
|
||||
assert resume_point["info_items"][0]["name"] == "用户名"
|
||||
assert resume_point["info_items"][0]["value"] == "zhangsan"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_session_without_redis(self, db_session):
|
||||
"""测试无 Redis 时暂停仍成功(降级处理)"""
|
||||
svc = AutoSessionService(db_session, redis=None)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="测试",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
result = await svc.pause_session(session.id)
|
||||
assert result.status == "paused"
|
||||
|
||||
|
||||
class TestResumeSession:
|
||||
"""恢复会话测试用例"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_session(self, db_session, mock_redis):
|
||||
"""测试恢复会话 — paused → running,Redis 恢复点清理"""
|
||||
# Arrange
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="终端定位",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
# 先暂停
|
||||
await svc.pause_session(session.id)
|
||||
assert session.status == "paused"
|
||||
|
||||
# Mock _run_executor 防止后台任务干扰
|
||||
with patch.object(
|
||||
AutoSessionService, "_run_executor", new_callable=AsyncMock
|
||||
):
|
||||
# Act
|
||||
result = await svc.resume_session("emp_001", session.id)
|
||||
|
||||
# Assert
|
||||
assert result["session"].status == "running"
|
||||
assert result["session"].paused_at is None
|
||||
assert result["need_select"] is False
|
||||
|
||||
# 验证 Redis 恢复点已清理
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session.id)
|
||||
raw = await mock_redis.get(resume_key)
|
||||
assert raw is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_multiple_paused(self, db_session, mock_redis):
|
||||
"""测试多暂停会话 — 返回 need_select=True 和列表"""
|
||||
# Arrange
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
for i in range(2):
|
||||
s = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title=f"任务{i}",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(s)
|
||||
await db_session.flush()
|
||||
await svc.pause_session(s.id)
|
||||
|
||||
# Act — 不指定 session_id
|
||||
result = await svc.resume_session("emp_001")
|
||||
|
||||
# Assert
|
||||
assert result["need_select"] is True
|
||||
assert result["session"] is None
|
||||
assert len(result["paused_list"]) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_single_paused_auto_select(self, db_session, mock_redis):
|
||||
"""测试单个暂停会话 — 自动选择恢复"""
|
||||
# Arrange
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="唯一任务",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
await svc.pause_session(session.id)
|
||||
|
||||
# Act — 不指定 session_id
|
||||
with patch.object(
|
||||
AutoSessionService, "_run_executor", new_callable=AsyncMock
|
||||
):
|
||||
result = await svc.resume_session("emp_001")
|
||||
|
||||
# Assert
|
||||
assert result["need_select"] is False
|
||||
assert result["session"] is not None
|
||||
assert result["session"].status == "running"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_no_paused(self, db_session, mock_redis):
|
||||
"""测试无暂停会话时恢复 — 返回空"""
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
|
||||
result = await svc.resume_session("emp_001")
|
||||
|
||||
assert result["session"] is None
|
||||
assert result["need_select"] is False
|
||||
assert result["paused_list"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_timeout_closed(self, db_session, mock_redis):
|
||||
"""测试超时关闭的会话不可恢复 — 应返回 4014 错误码"""
|
||||
# Arrange
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="已关闭任务",
|
||||
status="closed",
|
||||
closed_by="system(timeout)",
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(AutomationException) as exc_info:
|
||||
await svc.resume_session("emp_001", session.id)
|
||||
|
||||
assert exc_info.value.code == AutomationErrorCode.SESSION_NOT_RESUMABLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_running_session(self, db_session, mock_redis):
|
||||
"""测试恢复非 paused 状态会话 — 应返回 4014"""
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="运行中",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
with pytest.raises(AutomationException) as exc_info:
|
||||
await svc.resume_session("emp_001", session.id)
|
||||
|
||||
assert exc_info.value.code == AutomationErrorCode.SESSION_NOT_RESUMABLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_not_found(self, db_session, mock_redis):
|
||||
"""测试恢复不存在的会话 — 应抛出 4005"""
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
|
||||
with pytest.raises(AutomationException) as exc_info:
|
||||
await svc.resume_session("emp_001", "nonexistent_id")
|
||||
|
||||
assert exc_info.value.code == AutomationErrorCode.SESSION_NOT_FOUND
|
||||
|
||||
|
||||
class TestListPausedSessions:
|
||||
"""暂停会话列表测试用例"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paused_sessions(self, db_session, mock_redis):
|
||||
"""测试获取暂停会话列表"""
|
||||
# Arrange
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
for i in range(3):
|
||||
s = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title=f"任务{i}",
|
||||
scenario_key="terminal_locate" if i == 0 else "password_reset",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(s)
|
||||
await db_session.flush()
|
||||
await svc.pause_session(s.id)
|
||||
|
||||
# Act
|
||||
result = await svc.list_paused_sessions("emp_001")
|
||||
|
||||
# Assert
|
||||
assert len(result) == 3
|
||||
for item in result:
|
||||
assert "session_id" in item
|
||||
assert "title" in item
|
||||
assert "paused_at" in item
|
||||
assert "paused_duration" in item
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paused_sessions_empty(self, db_session, mock_redis):
|
||||
"""测试无暂停会话时返回空列表"""
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
result = await svc.list_paused_sessions("emp_999")
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paused_sessions_filter_by_employee(self, db_session, mock_redis):
|
||||
"""测试按员工过滤暂停会话"""
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
|
||||
# emp_001 的暂停会话
|
||||
s1 = AutoSession(employee_id="emp_001", title="任务1", status="running")
|
||||
db_session.add(s1)
|
||||
await db_session.flush()
|
||||
await svc.pause_session(s1.id)
|
||||
|
||||
# emp_002 的暂停会话
|
||||
s2 = AutoSession(employee_id="emp_002", title="任务2", status="running")
|
||||
db_session.add(s2)
|
||||
await db_session.flush()
|
||||
await svc.pause_session(s2.id)
|
||||
|
||||
result = await svc.list_paused_sessions("emp_001")
|
||||
assert len(result) == 1
|
||||
assert result[0]["title"] == "任务1"
|
||||
|
||||
|
||||
class TestAgentOperations:
|
||||
"""坐席操作测试用例"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_resume(self, db_session, mock_redis):
|
||||
"""测试坐席代恢复"""
|
||||
# Arrange
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="终端定位",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
await svc.pause_session(session.id)
|
||||
|
||||
# Act
|
||||
with patch.object(
|
||||
AutoSessionService, "_run_executor", new_callable=AsyncMock
|
||||
):
|
||||
result = await svc.agent_resume(session.id, "agent_001", note="代恢复")
|
||||
|
||||
# Assert
|
||||
assert result.status == "running"
|
||||
assert result.paused_at is None
|
||||
assert result.agent_id == "agent_001"
|
||||
assert result.closed_by == "agent:agent_001(resume)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_resume_not_paused(self, db_session, mock_redis):
|
||||
"""测试坐席代恢复非暂停会话 — 应返回 4014"""
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="运行中",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
with pytest.raises(AutomationException) as exc_info:
|
||||
await svc.agent_resume(session.id, "agent_001")
|
||||
|
||||
assert exc_info.value.code == AutomationErrorCode.SESSION_NOT_RESUMABLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_resume_not_found(self, db_session, mock_redis):
|
||||
"""测试坐席代恢复不存在的会话 — 应返回 4005"""
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
|
||||
with pytest.raises(AutomationException) as exc_info:
|
||||
await svc.agent_resume("nonexistent", "agent_001")
|
||||
|
||||
assert exc_info.value.code == AutomationErrorCode.SESSION_NOT_FOUND
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_close(self, db_session, mock_redis):
|
||||
"""测试坐席手动关闭暂停会话"""
|
||||
# Arrange
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="长时间暂停",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
await svc.pause_session(session.id)
|
||||
|
||||
# Act
|
||||
result = await svc.agent_close(session.id, "agent_001", note="手动关闭")
|
||||
|
||||
# Assert
|
||||
assert result.status == "closed"
|
||||
assert result.closed_by == "agent:agent_001(close)"
|
||||
assert result.agent_id == "agent_001"
|
||||
|
||||
# 验证 Redis 恢复点已清理
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session.id)
|
||||
raw = await mock_redis.get(resume_key)
|
||||
assert raw is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_close_not_paused(self, db_session, mock_redis):
|
||||
"""测试坐席关闭非暂停会话 — 应返回 4014"""
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="运行中",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
with pytest.raises(AutomationException) as exc_info:
|
||||
await svc.agent_close(session.id, "agent_001")
|
||||
|
||||
assert exc_info.value.code == AutomationErrorCode.SESSION_NOT_RESUMABLE
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_close_not_found(self, db_session, mock_redis):
|
||||
"""测试坐席关闭不存在的会话 — 应返回 4005"""
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
|
||||
with pytest.raises(AutomationException) as exc_info:
|
||||
await svc.agent_close("nonexistent", "agent_001")
|
||||
|
||||
assert exc_info.value.code == AutomationErrorCode.SESSION_NOT_FOUND
|
||||
|
||||
|
||||
class TestCorrectAndSupplementInfo:
|
||||
"""信息更正与补充(SessionManager 层)测试用例"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_correct_info(self, db_session, mock_redis):
|
||||
"""测试通过 SessionManager 更正信息"""
|
||||
# Arrange
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="密码重置",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
# 先创建信息项
|
||||
from app.services.automation.information_item_service import InformationItemService
|
||||
info_svc = InformationItemService(db_session, mock_redis)
|
||||
await info_svc.create_item(session.id, "用户名", "zhangsan", ["必需"])
|
||||
|
||||
# Act
|
||||
item = await svc.correct_info(session.id, "用户名", "lisi")
|
||||
|
||||
# Assert
|
||||
assert item.value == "lisi"
|
||||
assert item.version == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supplement_info(self, db_session, mock_redis):
|
||||
"""测试通过 SessionManager 补充信息"""
|
||||
# Arrange
|
||||
svc = AutoSessionService(db_session, mock_redis)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="病毒处置",
|
||||
status="running",
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
# Act — 补充不存在的信息项(自动创建)
|
||||
item = await svc.supplement_info(session.id, "备注", "财务部电脑")
|
||||
|
||||
# Assert
|
||||
assert item.value == "财务部电脑"
|
||||
assert item.version == 1
|
||||
@@ -0,0 +1,248 @@
|
||||
# =============================================================================
|
||||
# 复杂场景重构 — 超时清理定时任务单元测试
|
||||
# =============================================================================
|
||||
# 测试范围:timeout_cleaner.py
|
||||
# - run_once 24h 超时自动关闭
|
||||
# - run_once 无超时会话时不操作
|
||||
# - run_once Redis 恢复点清理
|
||||
# - run_once 推送 WS 超时关闭事件
|
||||
#
|
||||
# 技术说明:TimeoutCleaner.run_once() 内部使用 db_factory 创建独立 DB 会话并
|
||||
# 调用 commit()。测试中通过 MockDbFactory 复用 db_session(SAVEPOINT),
|
||||
# 并将 commit 替换为 flush,避免干扰 conftest 的事务回滚机制。
|
||||
# =============================================================================
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.constants import (
|
||||
PAUSE_TIMEOUT_HOURS,
|
||||
REDIS_KEY_PAUSED_SESSIONS,
|
||||
REDIS_KEY_RESUME_POINT,
|
||||
)
|
||||
from app.models.automation import AutoSession
|
||||
from app.services.automation.timeout_cleaner import TimeoutCleaner
|
||||
|
||||
|
||||
class MockDbFactory:
|
||||
"""模拟 DB 会话工厂,复用 db_session 的 SAVEPOINT 事务。
|
||||
|
||||
run_once() 内部调用 await db.commit(),在 SAVEPOINT 环境下会导致
|
||||
事务提前提交。此处将 commit 替换为 flush,确保数据写入 SAVEPOINT
|
||||
但不提交外层事务,测试结束后由 conftest 回滚清理。
|
||||
"""
|
||||
|
||||
def __init__(self, db_session):
|
||||
self._db = db_session
|
||||
|
||||
def __call__(self):
|
||||
return self
|
||||
|
||||
async def __aenter__(self):
|
||||
self._db.commit = AsyncMock(side_effect=self._db.flush)
|
||||
return self._db
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
class TestTimeoutCleaner:
|
||||
"""超时清理器测试用例"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_cleaner(self, db_session, mock_redis):
|
||||
"""测试 24h 超时自动关闭"""
|
||||
# Arrange — 创建一个 25 小时前暂停的会话
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="超时任务",
|
||||
status="paused",
|
||||
paused_at=datetime.now(timezone.utc) - timedelta(hours=25),
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
# Act
|
||||
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
|
||||
closed_count = await cleaner.run_once()
|
||||
|
||||
# Assert
|
||||
assert closed_count == 1
|
||||
await db_session.refresh(session)
|
||||
assert session.status == "closed"
|
||||
assert session.closed_by == "system(timeout)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_cleaner_no_expired(self, db_session, mock_redis):
|
||||
"""测试无超时会话时不操作"""
|
||||
# Arrange — 创建一个刚暂停的会话(未超时)
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="近期任务",
|
||||
status="paused",
|
||||
paused_at=datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
# Act
|
||||
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
|
||||
closed_count = await cleaner.run_once()
|
||||
|
||||
# Assert
|
||||
assert closed_count == 0
|
||||
await db_session.refresh(session)
|
||||
assert session.status == "paused"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_cleaner_redis_cleanup(self, db_session, mock_redis):
|
||||
"""测试超时关闭时清理 Redis 恢复点"""
|
||||
# Arrange
|
||||
session = AutoSession(
|
||||
employee_id="emp_redis_test",
|
||||
title="Redis清理测试",
|
||||
status="paused",
|
||||
paused_at=datetime.now(timezone.utc) - timedelta(hours=25),
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
session_id = session.id
|
||||
employee_id = session.employee_id
|
||||
|
||||
# 预置 Redis 恢复点和暂停集合
|
||||
resume_key = REDIS_KEY_RESUME_POINT.format(session_id=session_id)
|
||||
await mock_redis.setex(resume_key, 90000, '{"title":"test"}')
|
||||
paused_key = REDIS_KEY_PAUSED_SESSIONS.format(employee_id=employee_id)
|
||||
await mock_redis.sadd(paused_key, session_id)
|
||||
|
||||
# Act
|
||||
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
|
||||
await cleaner.run_once()
|
||||
|
||||
# Assert — Redis 恢复点已删除
|
||||
raw = await mock_redis.get(resume_key)
|
||||
assert raw is None
|
||||
|
||||
# Assert — 暂停集合中已移除
|
||||
members = await mock_redis.smembers(paused_key)
|
||||
assert session_id not in members
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_cleaner_multiple_sessions(self, db_session, mock_redis):
|
||||
"""测试多个超时会话一次性关闭"""
|
||||
# Arrange
|
||||
sessions = []
|
||||
for i in range(3):
|
||||
s = AutoSession(
|
||||
employee_id=f"emp_{i}",
|
||||
title=f"超时任务{i}",
|
||||
status="paused",
|
||||
paused_at=datetime.now(timezone.utc) - timedelta(hours=26),
|
||||
)
|
||||
db_session.add(s)
|
||||
sessions.append(s)
|
||||
await db_session.flush()
|
||||
|
||||
# Act
|
||||
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
|
||||
closed_count = await cleaner.run_once()
|
||||
|
||||
# Assert
|
||||
assert closed_count == 3
|
||||
for s in sessions:
|
||||
await db_session.refresh(s)
|
||||
assert s.status == "closed"
|
||||
assert s.closed_by == "system(timeout)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_cleaner_mixed_sessions(self, db_session, mock_redis):
|
||||
"""测试混合场景 — 只关闭超时的,不关闭未超时的"""
|
||||
# Arrange
|
||||
expired = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="超时",
|
||||
status="paused",
|
||||
paused_at=datetime.now(timezone.utc) - timedelta(hours=25),
|
||||
)
|
||||
recent = AutoSession(
|
||||
employee_id="emp_002",
|
||||
title="近期",
|
||||
status="paused",
|
||||
paused_at=datetime.now(timezone.utc) - timedelta(minutes=30),
|
||||
)
|
||||
db_session.add_all([expired, recent])
|
||||
await db_session.flush()
|
||||
|
||||
# Act
|
||||
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
|
||||
closed_count = await cleaner.run_once()
|
||||
|
||||
# Assert
|
||||
assert closed_count == 1
|
||||
await db_session.refresh(expired)
|
||||
assert expired.status == "closed"
|
||||
await db_session.refresh(recent)
|
||||
assert recent.status == "paused"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_cleaner_without_redis(self, db_session):
|
||||
"""测试无 Redis 时超时关闭仍成功"""
|
||||
# Arrange
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="无Redis超时",
|
||||
status="paused",
|
||||
paused_at=datetime.now(timezone.utc) - timedelta(hours=25),
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
# Act — redis=None
|
||||
cleaner = TimeoutCleaner(MockDbFactory(db_session), redis=None)
|
||||
closed_count = await cleaner.run_once()
|
||||
|
||||
# Assert
|
||||
assert closed_count == 1
|
||||
await db_session.refresh(session)
|
||||
assert session.status == "closed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_cleaner_boundary_just_expired(self, db_session, mock_redis):
|
||||
"""测试边界 — 刚好超过 24 小时的会话被关闭"""
|
||||
# Arrange — 24小时 + 1分钟前
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="边界测试",
|
||||
status="paused",
|
||||
paused_at=datetime.now(timezone.utc) - timedelta(hours=24, minutes=1),
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
# Act
|
||||
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
|
||||
closed_count = await cleaner.run_once()
|
||||
|
||||
# Assert
|
||||
assert closed_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_cleaner_boundary_just_under(self, db_session, mock_redis):
|
||||
"""测试边界 — 差 1 分钟到 24 小时的会话不被关闭"""
|
||||
# Arrange — 23小时59分钟前
|
||||
session = AutoSession(
|
||||
employee_id="emp_001",
|
||||
title="未超时边界",
|
||||
status="paused",
|
||||
paused_at=datetime.now(timezone.utc) - timedelta(hours=23, minutes=59),
|
||||
)
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
|
||||
# Act
|
||||
cleaner = TimeoutCleaner(MockDbFactory(db_session), mock_redis)
|
||||
closed_count = await cleaner.run_once()
|
||||
|
||||
# Assert
|
||||
assert closed_count == 0
|
||||
Reference in New Issue
Block a user