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:
Simon
2026-07-11 23:13:10 +08:00
parent 3d152fc8eb
commit bea288e414
928 changed files with 85169 additions and 54205 deletions
+1
View File
@@ -0,0 +1 @@
# 自动化测试包初始化
+62
View File
@@ -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_sessionrunning → pausedRedis 恢复点写入)
# - pause_session 终态不可暂停(4013
# - resume_sessionpaused → runningRedis 恢复点清理)
# - 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 → pausedRedis 恢复点写入"""
# 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 → runningRedis 恢复点清理"""
# 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_sessionSAVEPOINT),
# 并将 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