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
+23 -1
View File
@@ -37,6 +37,26 @@ def _patched_read_file(self, env_file, encoding=None):
_starlette_config.Config._read_file = _patched_read_file
# ---------------------------------------------------------------------------
# httpx.Timeout 兼容补丁: itsm_service.py 第33行使用 httpx.Timeout(connect=10.0, read=30.0)
# 新版 httpx 要求要么传 default, 要么四个参数(connect/read/write/pool)全传。
# 此补丁在 default 缺失时自动从已有参数推断默认值, 使旧代码兼容新版 httpx。
# 注意: 这是测试环境 workaround, 生产应由工程师修复 itsm_service.py。
# ---------------------------------------------------------------------------
import httpx as _httpx
_OrigTimeout = _httpx.Timeout
def _compat_timeout(timeout=None, **kwargs):
"""兼容旧版 httpx.Timeout 调用: 缺少 default 时自动补全。"""
if timeout is None and kwargs:
timeout = max(kwargs.values())
return _OrigTimeout(timeout, **kwargs)
_httpx.Timeout = _compat_timeout
import asyncio
import uuid
from datetime import datetime
@@ -77,6 +97,8 @@ from app.models.approval_link import ApprovalLink
from app.models.software_download import SoftwareDownload
from app.models.quick_reply_template import QuickReplyTemplate
from app.models.agent_note import AgentNote
from app.models.terminal_room_binding import TerminalRoomBinding
from app.models.meetingroom_booking_snapshot import MeetingroomBookingSnapshot
# =============================================================================
@@ -447,7 +469,7 @@ async def seeded_db(db_session: AsyncSession) -> AsyncSession:
# 趣味话术
phrases = [
FunnyPhrase(scene="shake", content="大哥,俺这就去摇人,稍等...", tone="亲切", sort_order=1),
FunnyPhrase(scene="shake", content="少主,这就为您去摇人,稍等...", tone="亲切", sort_order=1),
FunnyPhrase(scene="vip", content="这就帮您安排专家,请稍候", tone="正式", sort_order=1),
]
db_session.add_all(phrases)
@@ -0,0 +1,725 @@
# =============================================================================
# IT智能服务台 — 审批意图检测(/approval/detect-intent)测试
# =============================================================================
# 测试覆盖:
# 1. 关键词预过滤逻辑(_keyword_prefilter 单元测试)
# 2. 降级兜底逻辑(_fallback_detect 单元测试)
# 3. 端点集成测试:
# a. 关键词未命中 → 直接返回 false(不调 Dify)
# b. 关键词命中 + Dify 高置信度 → is_approval_request=true
# c. 关键词命中 + Dify 低置信度 → is_approval_request=false
# d. Dify 调用失败 → 降级兜底
# e. Dify 未配置 → 降级兜底
# f. Dify 返回 is_approval=false → is_approval_request=false
# 4. /approval/keywords 端点测试
# 5. /approval/jump 端点测试
#
# Round 2 变更:所有端点已改用 success_response() 包装,
# 响应格式为 {code: 0, data: {...}, message: "success"}
# 测试断言已适配新格式:data["data"]["field"] 而非 data["field"]
# =============================================================================
import pytest
from unittest.mock import AsyncMock, patch
from app.api.approval import (
_keyword_prefilter,
_fallback_detect,
KEYWORD_TO_APPROVAL_TYPE,
APPROVAL_PREFILTER_KEYWORDS,
)
from app.config import settings
# =============================================================================
# 单元测试:_keyword_prefilter
# =============================================================================
class TestKeywordPrefilter:
"""测试关键词预过滤函数。"""
def test_keyword_hit_device(self):
"""包含设备相关关键词时返回 True。"""
assert _keyword_prefilter("我需要申请一台电脑") is True
def test_keyword_hit_vpn(self):
"""包含 VPN 关键词时返回 True(大小写不敏感)。"""
assert _keyword_prefilter("VPN 连不上了") is True
assert _keyword_prefilter("vpn 连不上了") is True
def test_keyword_hit_software(self):
"""包含软件相关关键词时返回 True。"""
assert _keyword_prefilter("想申请一个软件") is True
def test_keyword_hit_generic(self):
"""包含通用关键词'申请'时返回 True。"""
assert _keyword_prefilter("我想申请一个东西") is True
def test_keyword_miss_normal_message(self):
"""普通对话消息不包含审批关键词时返回 False。"""
assert _keyword_prefilter("你好,今天天气怎么样") is False
assert _keyword_prefilter("谢谢,问题解决了") is False
def test_keyword_miss_empty_string(self):
"""空字符串返回 False。"""
assert _keyword_prefilter("") is False
def test_keyword_miss_none(self):
"""None 返回 False。"""
assert _keyword_prefilter(None) is False # type: ignore[arg-type]
def test_keyword_case_insensitive(self):
"""关键词匹配大小写不敏感。"""
assert _keyword_prefilter("我的VPN坏了") is True
assert _keyword_prefilter("我的vpn坏了") is True
assert _keyword_prefilter("我的Vpn坏了") is True
def test_keyword_partial_match(self):
"""关键词是子串匹配('设备''设备申请'中也能命中)。"""
assert _keyword_prefilter("设备申请流程是什么") is True
def test_keyword_all_prefilter_keywords_work(self):
"""验证 APPROVAL_PREFILTER_KEYWORDS 中的每个关键词都能被命中。"""
for kw in APPROVAL_PREFILTER_KEYWORDS:
# 构造包含该关键词的文本
text = f"测试文本包含{kw}关键词"
assert _keyword_prefilter(text) is True, f"关键词 '{kw}' 未被命中"
# =============================================================================
# 单元测试:_fallback_detect
# =============================================================================
class TestFallbackDetect:
"""测试降级兜底检测函数。"""
def test_fallback_returns_true_for_keyword_text(self):
"""包含关键词的文本,兜底返回 is_approval_request=True。"""
is_approval, confidence, approval_type = _fallback_detect("我需要申请电脑")
assert is_approval is True
assert confidence == 0.6
def test_fallback_returns_correct_type_device(self):
"""设备相关关键词映射到'设备申请'"""
_, _, approval_type = _fallback_detect("我要申请电脑")
assert approval_type == "设备申请"
def test_fallback_returns_correct_type_vpn(self):
"""VPN 关键词映射到'账号权限申请'"""
_, _, approval_type = _fallback_detect("VPN连不上了")
assert approval_type == "账号权限申请"
def test_fallback_returns_correct_type_software(self):
"""软件关键词映射到'软件服务申请'"""
_, _, approval_type = _fallback_detect("想安装一个软件")
assert approval_type == "软件服务申请"
def test_fallback_returns_correct_type_disposal(self):
"""资产处置关键词映射到'资产处置申请'
注意:测试文本不能同时包含'电脑'(→设备申请)等排在'报废'前面的关键词,
否则 _fallback_detect 会因字典遍历顺序返回先匹配的类型。
"""
_, _, approval_type = _fallback_detect("这台机器要报废")
assert approval_type == "资产处置申请"
def test_fallback_returns_correct_type_office_supplies(self):
"""办公用品关键词映射到'办公用品申请'"""
_, _, approval_type = _fallback_detect("办公用品超额了")
assert approval_type == "办公用品申请"
def test_fallback_confidence_is_0_6(self):
"""兜底置信度固定为 0.6(低于阈值 0.7)。"""
_, confidence, _ = _fallback_detect("申请设备")
assert confidence == 0.6
def test_fallback_empty_text_returns_true_no_type(self):
"""空文本兜底仍返回 True(预过滤已通过),但 approval_type 为 None。"""
is_approval, confidence, approval_type = _fallback_detect("")
assert is_approval is True
assert confidence == 0.6
assert approval_type is None
def test_fallback_generic_keyword_no_type_mapping(self):
"""'申请'在预过滤列表但不在 KEYWORD_TO_APPROVAL_TYPE 中,兜底 approval_type 为 None。"""
# "申请" 在 APPROVAL_PREFILTER_KEYWORDS 中,但不在 KEYWORD_TO_APPROVAL_TYPE 中
is_approval, _, approval_type = _fallback_detect("申请")
assert is_approval is True
assert approval_type is None
def test_fallback_first_match_wins(self):
"""兜底遍历 KEYWORD_TO_APPROVAL_TYPE,命中第一个关键词即返回(字典有序)。"""
# 包含多个关键词的文本,应返回第一个匹配的类型
_, _, approval_type = _fallback_detect("设备和VPN都坏了")
# KEYWORD_TO_APPROVAL_TYPE 中"设备"排在"VPN"前面
assert approval_type is not None
assert approval_type in KEYWORD_TO_APPROVAL_TYPE.values()
# =============================================================================
# 集成测试:/approval/detect-intent 端点
# =============================================================================
# Round 2: 响应格式已改为 {code: 0, data: {...}, message: "success"}
# 所有断言通过 data["data"]["field"] 访问业务字段
# =============================================================================
class TestDetectApprovalIntentEndpoint:
"""测试 /approval/detect-intent API 端点。"""
@pytest.mark.asyncio
async def test_keyword_not_hit_returns_false_no_dify(self, client):
"""关键词未命中 → 直接返回 false,不调 Difysource=keyword_prefilter。"""
response = await client.post(
"/approval/detect-intent",
json={"text": "你好,今天天气怎么样"},
)
assert response.status_code == 200
data = response.json()
# 统一响应格式验证
assert data["code"] == 0
assert data["message"] == "success"
# 业务字段验证
inner = data["data"]
assert inner["is_approval_request"] is False
assert inner["confidence"] == 0.0
assert inner["approval_type"] is None
assert inner["source"] == "keyword_prefilter"
@pytest.mark.asyncio
async def test_keyword_not_hit_empty_text(self, client):
"""空文本 → 关键词未命中,返回 false。"""
response = await client.post(
"/approval/detect-intent",
json={"text": ""},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
inner = data["data"]
assert inner["is_approval_request"] is False
assert inner["source"] == "keyword_prefilter"
@pytest.mark.asyncio
async def test_dify_high_confidence_returns_true(self, client, monkeypatch):
"""关键词命中 + Dify 返回高置信度(≥0.7) → is_approval_request=true, source=dify。"""
# 设置 Dify 配置(模拟已配置)
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
# Mock Dify 返回高置信度
mock_dify = AsyncMock(return_value={
"is_approval_request": True,
"confidence": 0.95,
"approval_type": "设备申请",
})
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
response = await client.post(
"/approval/detect-intent",
json={"text": "我需要申请一台笔记本电脑"},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["message"] == "success"
inner = data["data"]
assert inner["is_approval_request"] is True
assert inner["confidence"] == 0.95
assert inner["approval_type"] == "设备申请"
assert inner["source"] == "dify"
# 验证 Dify 被调用了一次
mock_dify.assert_called_once()
@pytest.mark.asyncio
async def test_dify_confidence_at_threshold_returns_true(self, client, monkeypatch):
"""置信度恰好等于阈值(0.7) → is_approval_request=true>= 判断)。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
mock_dify = AsyncMock(return_value={
"is_approval_request": True,
"confidence": 0.7,
"approval_type": "账号权限申请",
})
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
response = await client.post(
"/approval/detect-intent",
json={"text": "VPN 账号申请"},
)
data = response.json()
assert data["code"] == 0
inner = data["data"]
assert inner["is_approval_request"] is True
assert inner["confidence"] == 0.7
assert inner["source"] == "dify"
@pytest.mark.asyncio
async def test_dify_low_confidence_returns_false(self, client, monkeypatch):
"""关键词命中 + Dify 返回低置信度(<0.7) → is_approval_request=false, source=dify。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
mock_dify = AsyncMock(return_value={
"is_approval_request": True,
"confidence": 0.5,
"approval_type": "设备申请",
})
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
response = await client.post(
"/approval/detect-intent",
json={"text": "我想申请一台电脑"},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
inner = data["data"]
assert inner["is_approval_request"] is False
assert inner["confidence"] == 0.5
assert inner["approval_type"] == "设备申请"
assert inner["source"] == "dify"
@pytest.mark.asyncio
async def test_dify_returns_is_approval_false(self, client, monkeypatch):
"""Dify 明确返回 is_approval_request=false → 端点返回 false。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
mock_dify = AsyncMock(return_value={
"is_approval_request": False,
"confidence": 0.3,
"approval_type": None,
})
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
response = await client.post(
"/approval/detect-intent",
json={"text": "我的电脑卡了"},
)
data = response.json()
assert data["code"] == 0
inner = data["data"]
assert inner["is_approval_request"] is False
assert inner["confidence"] == 0.3
assert inner["approval_type"] is None
assert inner["source"] == "dify"
@pytest.mark.asyncio
async def test_dify_failure_fallback(self, client, monkeypatch):
"""关键词命中 + Dify 调用失败 → 降级兜底, source=fallback。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
# Mock Dify 抛出异常(模拟网络错误/超时)
mock_dify = AsyncMock(side_effect=Exception("Dify 服务不可达"))
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
response = await client.post(
"/approval/detect-intent",
json={"text": "我需要申请一台电脑"},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
inner = data["data"]
# 兜底返回 is_approval_request=True, confidence=0.6
assert inner["is_approval_request"] is True
assert inner["confidence"] == 0.6
assert inner["approval_type"] == "设备申请"
assert inner["source"] == "fallback"
@pytest.mark.asyncio
async def test_dify_not_configured_fallback(self, client, monkeypatch):
"""Dify 未配置(base_url/api_key 为空)→ 降级兜底。"""
# 确保配置为空
monkeypatch.setattr(settings, "approval_dify_base_url", "")
monkeypatch.setattr(settings, "approval_dify_api_key", "")
response = await client.post(
"/approval/detect-intent",
json={"text": "VPN 账号申请"},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
inner = data["data"]
assert inner["is_approval_request"] is True
assert inner["confidence"] == 0.6
assert inner["approval_type"] == "账号权限申请"
assert inner["source"] == "fallback"
@pytest.mark.asyncio
async def test_fallback_with_generic_keyword_no_type(self, client, monkeypatch):
"""兜底场景:'申请'在预过滤列表但无类型映射 → approval_type=None。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "")
monkeypatch.setattr(settings, "approval_dify_api_key", "")
response = await client.post(
"/approval/detect-intent",
json={"text": "我要申请一个东西"},
)
data = response.json()
assert data["code"] == 0
inner = data["data"]
assert inner["is_approval_request"] is True
assert inner["confidence"] == 0.6
assert inner["approval_type"] is None
assert inner["source"] == "fallback"
@pytest.mark.asyncio
async def test_custom_threshold_override(self, client, monkeypatch):
"""自定义阈值:阈值设为 0.5Dify 返回 0.6 → is_approval_request=true。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.5)
mock_dify = AsyncMock(return_value={
"is_approval_request": True,
"confidence": 0.6,
"approval_type": "设备申请",
})
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
response = await client.post(
"/approval/detect-intent",
json={"text": "申请设备"},
)
data = response.json()
assert data["code"] == 0
inner = data["data"]
# 阈值 0.5,置信度 0.6 >= 0.5 → True
assert inner["is_approval_request"] is True
assert inner["source"] == "dify"
@pytest.mark.asyncio
async def test_employee_id_passed_to_dify(self, client, monkeypatch):
"""验证 employee_id 被传递给 Dify 调用。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
mock_dify = AsyncMock(return_value={
"is_approval_request": True,
"confidence": 0.9,
"approval_type": "设备申请",
})
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
await client.post(
"/approval/detect-intent",
json={"text": "申请电脑", "employee_id": "test_emp_001"},
)
# 验证 employee_id 被传给 Dify
mock_dify.assert_called_once_with("申请电脑", "test_emp_001")
@pytest.mark.asyncio
async def test_employee_id_defaults_to_empty_string(self, client, monkeypatch):
"""未提供 employee_id 时,传给 Dify 的是空字符串。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
mock_dify = AsyncMock(return_value={
"is_approval_request": True,
"confidence": 0.9,
"approval_type": "设备申请",
})
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
await client.post(
"/approval/detect-intent",
json={"text": "申请电脑"},
)
# 验证 employee_id 默认为空字符串
mock_dify.assert_called_once_with("申请电脑", "")
@pytest.mark.asyncio
async def test_response_has_all_required_fields(self, client):
"""验证响应包含 ApprovalDetectIntentResponse 的所有必需字段。"""
response = await client.post(
"/approval/detect-intent",
json={"text": "你好"},
)
data = response.json()
# 统一响应格式验证
assert data["code"] == 0
assert data["message"] == "success"
# 业务字段验证
inner = data["data"]
# ApprovalDetectIntentResponse 必需字段
assert "is_approval_request" in inner
assert "confidence" in inner
assert "source" in inner
# approval_type 是 Optional,可以为 None 但字段必须存在
assert "approval_type" in inner
@pytest.mark.asyncio
async def test_response_field_types(self, client):
"""验证响应字段类型正确。"""
response = await client.post(
"/approval/detect-intent",
json={"text": "你好"},
)
data = response.json()
assert data["code"] == 0
inner = data["data"]
assert isinstance(inner["is_approval_request"], bool)
assert isinstance(inner["confidence"], (int, float))
assert isinstance(inner["source"], str)
@pytest.mark.asyncio
async def test_dify_not_called_when_keyword_misses(self, client, monkeypatch):
"""关键词未命中时,Dify 不被调用。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
mock_dify = AsyncMock(return_value={
"is_approval_request": True,
"confidence": 0.9,
"approval_type": "设备申请",
})
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
response = await client.post(
"/approval/detect-intent",
json={"text": "今天天气真好"},
)
# Dify 不应被调用
mock_dify.assert_not_called()
data = response.json()
assert data["code"] == 0
assert data["data"]["source"] == "keyword_prefilter"
# =============================================================================
# 集成测试:/approval/keywords 端点
# =============================================================================
# Round 2 新增:验证 /approval/keywords 端点使用 success_response() 包装
# =============================================================================
class TestApprovalKeywordsEndpoint:
"""测试 /approval/keywords API 端点。"""
@pytest.mark.asyncio
async def test_keywords_response_format(self, client):
"""验证 /approval/keywords 响应使用统一格式 {code, data, message}。"""
response = await client.get("/approval/keywords")
assert response.status_code == 200
data = response.json()
# 统一响应格式验证
assert data["code"] == 0
assert data["message"] == "success"
# data 应为列表
assert isinstance(data["data"], list)
@pytest.mark.asyncio
async def test_keywords_empty_when_no_templates(self, client):
"""无审批模板配置时,返回空列表。"""
# 测试环境未设置 APPROVAL_TEMPLATE_RESOURCE / APPROVAL_TEMPLATE_DEVICE
# APPROVAL_TEMPLATES 为空字典
response = await client.get("/approval/keywords")
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"] == []
@pytest.mark.asyncio
async def test_keywords_with_mocked_templates(self, client):
"""模拟有审批模板时,返回关键词列表。"""
mock_templates = {
"tpl_resource_001": {
"id": "tpl_resource_001",
"name": "资源申请",
"type": "jump",
"keywords": ["申请资源", "要资源"],
},
"tpl_device_001": {
"id": "tpl_device_001",
"name": "设备申请",
"type": "api",
"keywords": ["申请设备", "要设备"],
},
}
with patch("app.api.approval.APPROVAL_TEMPLATES", mock_templates):
response = await client.get("/approval/keywords")
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["message"] == "success"
keywords = data["data"]
assert len(keywords) == 4 # 2 模板 × 2 关键词
# 验证每个关键词条目的结构
for item in keywords:
assert "keyword" in item
assert "template_id" in item
assert "template_name" in item
assert "type" in item
# 验证包含预期的关键词
keyword_values = [item["keyword"] for item in keywords]
assert "申请资源" in keyword_values
assert "申请设备" in keyword_values
@pytest.mark.asyncio
async def test_keywords_data_is_list_type(self, client):
"""验证 data 字段始终为列表类型(即使为空)。"""
response = await client.get("/approval/keywords")
data = response.json()
assert isinstance(data["data"], list)
# =============================================================================
# 集成测试:/approval/jump 端点
# =============================================================================
# Round 2 新增:验证 /approval/jump 端点使用 success_response() 包装
# =============================================================================
class TestApprovalJumpEndpoint:
"""测试 /approval/jump API 端点。"""
@pytest.mark.asyncio
async def test_jump_template_not_found(self, client):
"""模板不存在时返回 404。"""
response = await client.post(
"/approval/jump",
json={"template_id": "non_existent_template"},
)
assert response.status_code == 404
assert "模板不存在" in response.json()["detail"]
@pytest.mark.asyncio
async def test_jump_type_mismatch(self, client):
"""模板类型不支持跳转(type != 'jump')时返回 400。"""
mock_templates = {
"tpl_api_001": {
"id": "tpl_api_001",
"name": "API审批",
"type": "api", # 非 jump 类型
"keywords": ["测试"],
},
}
with patch("app.api.approval.APPROVAL_TEMPLATES", mock_templates):
response = await client.post(
"/approval/jump",
json={"template_id": "tpl_api_001"},
)
assert response.status_code == 400
assert "不支持跳转方式" in response.json()["detail"]
@pytest.mark.asyncio
async def test_jump_success_response_format(self, client):
"""跳转成功时返回统一格式 {code: 0, data: {url, template_name}, message}。"""
mock_templates = {
"tpl_jump_001": {
"id": "tpl_jump_001",
"name": "资源申请",
"type": "jump",
"keywords": ["申请资源"],
},
}
with patch("app.api.approval.APPROVAL_TEMPLATES", mock_templates):
response = await client.post(
"/approval/jump",
json={"template_id": "tpl_jump_001"},
)
assert response.status_code == 200
data = response.json()
# 统一响应格式验证
assert data["code"] == 0
assert data["message"] == "success"
# 业务字段验证
inner = data["data"]
assert "url" in inner
assert "template_name" in inner
assert inner["template_name"] == "资源申请"
# URL 应包含 template_id
assert "tpl_jump_001" in inner["url"]
@pytest.mark.asyncio
async def test_jump_url_contains_template_id(self, client):
"""跳转 URL 中包含 template_id。"""
mock_templates = {
"tpl_jump_002": {
"id": "tpl_jump_002",
"name": "设备申请",
"type": "jump",
"keywords": ["申请设备"],
},
}
with patch("app.api.approval.APPROVAL_TEMPLATES", mock_templates):
response = await client.post(
"/approval/jump",
json={"template_id": "tpl_jump_002"},
)
data = response.json()
inner = data["data"]
assert "tpl_jump_002" in inner["url"]
@pytest.mark.asyncio
async def test_jump_with_employee_id(self, client):
"""携带 employee_id 时跳转正常返回(employee_id 可选字段)。"""
mock_templates = {
"tpl_jump_003": {
"id": "tpl_jump_003",
"name": "资源申请",
"type": "jump",
"keywords": ["申请资源"],
},
}
with patch("app.api.approval.APPROVAL_TEMPLATES", mock_templates):
response = await client.post(
"/approval/jump",
json={
"template_id": "tpl_jump_003",
"employee_id": "test_emp_001",
},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"]["template_name"] == "资源申请"
@pytest.mark.asyncio
async def test_jump_missing_template_id(self, client):
"""缺少 template_id 时返回 422Pydantic 校验失败)。"""
response = await client.post(
"/approval/jump",
json={},
)
assert response.status_code == 422
+382
View File
@@ -0,0 +1,382 @@
# =============================================================================
# IT智能服务台 — 资产升级审批智能推送功能测试
# =============================================================================
# 测试覆盖:
# AssetService:
# 1. find_asset — 查找资产(存在/不存在/大小写/空格)
# 2. _parse_date — 多种日期格式解析
# 3. _format_years — 年限格式化
# 4. check_device_age — 完整核查流程
# 5. _format_opinion — 审批意见生成
# approval.py:
# 6. _extract_asset_code — 提取资产编号(Text/Selector/未找到)
# 7. _extract_current_approver — 提取审批人(list/dict/无审批中)
# 8. _build_urge_description — 构建卡片描述
# =============================================================================
import os
from datetime import date, datetime
import pytest
from app.services.asset_service import AssetService
from app.api.approval import (
_extract_asset_code,
_extract_current_approver,
_build_urge_description,
)
# =============================================================================
# 测试常量
# =============================================================================
EXCEL_PATH = r"D:\资料\00-工作文件\03-资产管理\固定资产清单\资产记录\2025资产\2025资产1~12.xlsx"
KNOWN_ASSET_CODE = "01011801-02012-041698"
# 检查 Excel 文件是否存在(不存在则跳过依赖真实文件的测试)
excel_exists = os.path.isfile(EXCEL_PATH)
# =============================================================================
# AssetService — find_asset 测试
# =============================================================================
class TestFindAsset:
"""find_asset 方法测试"""
@pytest.fixture(scope="class")
def asset_service(self):
"""创建 AssetService 实例(类级共享,避免重复加载 18000 行 Excel"""
if not excel_exists:
pytest.skip(f"Excel 文件不存在: {EXCEL_PATH}")
service = AssetService(excel_path=EXCEL_PATH)
yield service
service.close()
def test_find_asset_found(self, asset_service):
"""查找已知存在的资产编号,验证返回dict包含正确字段"""
result = asset_service.find_asset(KNOWN_ASSET_CODE)
assert result is not None, f"未找到资产编号: {KNOWN_ASSET_CODE}"
assert isinstance(result, dict)
# 验证关键字段存在
assert result.get("固定资产编码") is not None
assert "sheet_name" in result
assert "固定资产名称" in result
assert "开始使用日期" in result
def test_find_asset_not_found(self, asset_service):
"""查找不存在的资产编号,验证返回 None"""
result = asset_service.find_asset("NONEXISTENT-CODE-99999")
assert result is None
def test_find_asset_case_insensitive(self, asset_service):
"""大小写不敏感匹配测试"""
# 资产编号全小写搜索(编号本身无字母,验证 lower() 逻辑不报错)
result = asset_service.find_asset(KNOWN_ASSET_CODE.lower())
assert result is not None, "大小写不敏感匹配失败"
def test_find_asset_trim_whitespace(self, asset_service):
"""前后空格trim测试"""
result = asset_service.find_asset(f" {KNOWN_ASSET_CODE} ")
assert result is not None, "trim 空格后匹配失败"
# =============================================================================
# AssetService — _parse_date 测试
# =============================================================================
class TestParseDate:
"""_parse_date 方法测试(纯逻辑,不需要 Excel 文件)"""
@pytest.fixture
def service(self):
"""创建 AssetService 实例(不触发 Excel 加载)"""
return AssetService(excel_path=EXCEL_PATH)
def test_parse_date_formats(self, service):
"""测试多种日期格式解析:datetime/date/字符串/Excel序列号"""
# datetime 对象 → 取 date 部分
dt = datetime(2025, 1, 23, 10, 30, 0)
assert service._parse_date(dt) == date(2025, 1, 23)
# date 对象 → 直接返回
d = date(2025, 1, 23)
assert service._parse_date(d) == date(2025, 1, 23)
# 字符串 — 4 种格式
assert service._parse_date("2025-01-23") == date(2025, 1, 23)
assert service._parse_date("2025/01/23") == date(2025, 1, 23)
assert service._parse_date("2025.01.23") == date(2025, 1, 23)
assert service._parse_date("2025年01月23日") == date(2025, 1, 23)
# Excel 序列号(date(1899,12,30) + N days = 目标日期)
excel_serial = (date(2025, 1, 23) - date(1899, 12, 30)).days
assert service._parse_date(excel_serial) == date(2025, 1, 23)
# 浮点数序列号也能解析
assert service._parse_date(float(excel_serial)) == date(2025, 1, 23)
def test_parse_date_none(self, service):
"""None 输入返回 None"""
assert service._parse_date(None) is None
# =============================================================================
# AssetService — _format_years 测试
# =============================================================================
class TestFormatYears:
"""_format_years 方法测试(纯逻辑)"""
@pytest.fixture
def service(self):
return AssetService(excel_path=EXCEL_PATH)
def test_format_years(self, service):
"""测试浮点年限格式化"""
# 5.17 年 → int(5.17*12)=62 → 5年2个月
assert service._format_years(5.17) == "5年2个月"
# 0.5 年 → int(0.5*12)=6 → 0年6个月
assert service._format_years(0.5) == "0年6个月"
# 整数年限 5.0 → 60个月 → 5年0个月
assert service._format_years(5.0) == "5年0个月"
# 0 年
assert service._format_years(0.0) == "0年0个月"
# =============================================================================
# AssetService — check_device_age 测试
# =============================================================================
class TestCheckDeviceAge:
"""check_device_age 方法测试"""
@pytest.fixture(scope="class")
def asset_service(self):
if not excel_exists:
pytest.skip(f"Excel 文件不存在: {EXCEL_PATH}")
service = AssetService(excel_path=EXCEL_PATH)
yield service
service.close()
def test_check_device_age_found(self, asset_service):
"""测试完整核查流程(真实Excel),验证返回dict结构正确"""
result = asset_service.check_device_age(KNOWN_ASSET_CODE, threshold_years=5)
assert isinstance(result, dict)
assert result["found"] is True
assert result["asset_code"] == KNOWN_ASSET_CODE
assert "asset_name" in result
assert "start_date" in result
assert "years_used" in result
assert "years_display" in result
assert "meets_threshold" in result
assert "opinion" in result
assert isinstance(result["meets_threshold"], bool)
def test_check_device_age_not_found(self, asset_service):
"""资产不存在时的核查结果"""
result = asset_service.check_device_age("NONEXISTENT-CODE-99999")
assert isinstance(result, dict)
assert result["found"] is False
assert "opinion" in result
assert "未在资产清单中找到" in result["opinion"]
# =============================================================================
# AssetService — _format_opinion 测试
# =============================================================================
class TestFormatOpinion:
"""_format_opinion 方法测试(纯逻辑)"""
@pytest.fixture
def service(self):
return AssetService(excel_path=EXCEL_PATH)
def test_format_opinion_meets(self, service):
"""满足5年条件的意见文本格式"""
opinion = service._format_opinion(
asset_name="电脑笔记本",
start_date=date(2020, 1, 1),
years_used=5.5,
threshold=5,
meets=True,
)
assert "电脑笔记本" in opinion
assert "2020-01-01" in opinion
assert "" in opinion
assert "已满5年" in opinion
assert "符合更换条件" in opinion
def test_format_opinion_not_meets(self, service):
"""不满足5年条件的意见文本格式"""
opinion = service._format_opinion(
asset_name="显示器",
start_date=date(2023, 6, 15),
years_used=2.0,
threshold=5,
meets=False,
)
assert "显示器" in opinion
assert "2023-06-15" in opinion
assert "" in opinion
assert "未满5年" in opinion
assert "不符合更换条件" in opinion
# =============================================================================
# approval.py — _extract_asset_code 测试
# =============================================================================
class TestExtractAssetCode:
"""_extract_asset_code 函数测试"""
def test_extract_asset_code_text(self):
"""Text 控件类型的资产编号提取"""
detail = {
"info": {
"apply_data": {
"contents": [
{
"control": "Text",
"id": "Text-1",
"title": [{"text": "资产编号", "lang": "zh_CN"}],
"value": {"text": "01011801-02012-041698"}
}
]
}
}
}
result = _extract_asset_code(detail)
assert result == "01011801-02012-041698"
def test_extract_asset_code_selector(self):
"""Selector 控件类型的资产编号提取"""
detail = {
"info": {
"apply_data": {
"contents": [
{
"control": "Selector",
"id": "Selector-1",
"title": [{"text": "固定资产编号", "lang": "zh_CN"}],
"value": {"value": "01011801-02012-041698"}
}
]
}
}
}
result = _extract_asset_code(detail)
assert result == "01011801-02012-041698"
def test_extract_asset_code_not_found(self):
"""表单中无资产编号字段时返回 None"""
detail = {
"info": {
"apply_data": {
"contents": [
{
"control": "Text",
"id": "Text-1",
"title": [{"text": "申请理由", "lang": "zh_CN"}],
"value": {"text": "电脑太旧了"}
}
]
}
}
}
result = _extract_asset_code(detail)
assert result is None
# =============================================================================
# approval.py — _extract_current_approver 测试
# =============================================================================
class TestExtractCurrentApprover:
"""_extract_current_approver 函数测试"""
def test_extract_current_approver_list(self):
"""approver 为列表格式时的提取(企微API标准格式)"""
detail = {
"info": {
"sp_record": [
{
"status": 1,
"type": 1,
"approverattr": 1,
"approver": [
{"userid": "zhangsan", "partyid": "2"}
]
}
]
}
}
result = _extract_current_approver(detail)
assert result == "zhangsan"
def test_extract_current_approver_dict(self):
"""approver 为 dict 格式时的兼容提取"""
detail = {
"info": {
"sp_record": [
{
"status": 1,
"type": 1,
"approverattr": 1,
"approver": {"userid": "lisi", "partyid": "3"}
}
]
}
}
result = _extract_current_approver(detail)
assert result == "lisi"
def test_extract_current_approver_no_pending(self):
"""无审批中节点时返回 None"""
detail = {
"info": {
"sp_record": [
{
"status": 2, # 已通过,非审批中
"type": 1,
"approver": [{"userid": "zhangsan", "partyid": "2"}]
}
]
}
}
result = _extract_current_approver(detail)
assert result is None
# =============================================================================
# approval.py — _build_urge_description 测试
# =============================================================================
class TestBuildUrgeDescription:
"""_build_urge_description 函数测试"""
def test_build_urge_description(self):
"""验证卡片描述文本包含所有必要字段"""
check_result = {
"found": True,
"asset_code": "01011801-02012-041698",
"asset_name": "电脑笔记本",
"start_date": "2020-01-23",
"years_display": "5年6个月",
"meets_threshold": True,
}
applyer_userid = "sxn"
desc = _build_urge_description(check_result, applyer_userid)
# 验证所有关键字段都在描述中
assert "01011801-02012-041698" in desc
assert "电脑笔记本" in desc
assert "2020-01-23" in desc
assert "5年6个月" in desc
assert "" in desc
assert "sxn" in desc
assert "请点击查看审批详情" in desc
+232
View File
@@ -0,0 +1,232 @@
# =============================================================================
# 企微IT智能服务台 — 统一认证 API 测试
# =============================================================================
# 测试覆盖:
# 1. GET /auth/qrcode — 获取扫码登录二维码
# 2. GET /auth/scan/status — 轮询扫码状态
# 3. POST /auth/verify — 验证 Token
# 4. POST /auth/logout — 登出
# 5. GET /auth/me — 获取当前用户信息
# 6. POST /auth/switch-role — 切换角色
#
# 说明:这些端点复用现有的 auth_qrcode 服务,因此重点测试统一认证 API 层的封装
# =============================================================================
import json
import secrets
from datetime import datetime
import pytest
from tests.conftest import MockRedis
# --------------------------------------------------------------------------
# 工具: 创建测试 Token 并写入 Redis
# --------------------------------------------------------------------------
async def _create_test_token(
mock_redis: MockRedis,
employee_id: str = "test-user-001",
name: str = "测试用户",
roles: list = None,
login_source: str = "h5",
) -> str:
"""在 mock_redis 里手动写一个用户 token,返回 token 字符串。
与 TokenService.create_token 一致: 写 user:token:{token}
"""
if roles is None:
roles = ["user"]
token = secrets.token_urlsafe(32)
token_data = {
"employee_id": employee_id,
"name": name,
"department": "测试部",
"avatar": "",
"roles": roles,
"current_role": roles[0] if roles else "user",
"login_source": login_source,
"created_at": datetime.now().isoformat(),
"last_active": datetime.now().isoformat(),
}
await mock_redis.setex(
f"user:token:{token}",
8 * 60 * 60,
json.dumps(token_data, ensure_ascii=False),
)
return token
# --------------------------------------------------------------------------
# 1. GET /auth/qrcode — 获取扫码登录二维码
# --------------------------------------------------------------------------
class TestQrcodeEndpoint:
"""测试统一认证的二维码获取端点。"""
@pytest.mark.asyncio
async def test_get_qrcode_returns_ticket_and_url(self, client, mock_redis):
"""验证获取二维码返回 ticket + qrcode_url + qrcode_png_base64 + expires_in。"""
response = await client.get("/auth/qrcode")
assert response.status_code == 200
body = response.json()
assert body["code"] == 0
assert "data" in body
assert body["data"] is not None
data = body["data"]
assert "ticket" in data
assert len(data["ticket"]) >= 16
assert "qrcode_url" in data
assert "qrcode_png_base64" in data
assert "expires_in" in data
assert "expires_at" in data
# 有效期应该是 120 秒 (从 QrcodeService 来的)
assert data["expires_in"] == 120
# --------------------------------------------------------------------------
# 2. POST /auth/verify — 验证 Token
# --------------------------------------------------------------------------
class TestVerifyTokenEndpoint:
"""测试统一认证的 Token 验证端点。"""
@pytest.mark.asyncio
async def test_verify_invalid_token_returns_false(self, client, mock_redis):
"""验证无效 Token 返回 valid=False。"""
response = await client.post(
"/auth/verify",
json={"token": "invalid-token-xyz"},
)
assert response.status_code == 200
body = response.json()
assert body["code"] == 0
data = body["data"]
assert data["valid"] is False
assert data["employee_id"] is None
assert data["name"] is None
@pytest.mark.asyncio
async def test_verify_blacklisted_token_returns_false(self, client, mock_redis):
"""验证已加入黑名单的 Token 返回 valid=False。"""
# 创建 token
token = await _create_test_token(
mock_redis,
employee_id="test-user-001",
name="测试用户",
)
# 将 token 加入黑名单
import hashlib
token_hash = hashlib.sha256(token.encode()).hexdigest()
await mock_redis.setex(f"token:blacklist:{token_hash}", 8 * 60 * 60, "1")
# 验证黑名单中的 token
response = await client.post(
"/auth/verify",
json={"token": token},
)
assert response.status_code == 200
body = response.json()
assert body["code"] == 0
data = body["data"]
assert data["valid"] is False
# --------------------------------------------------------------------------
# 3. POST /auth/logout — 登出
# --------------------------------------------------------------------------
class TestLogoutEndpoint:
"""测试统一认证的登出端点。"""
@pytest.mark.asyncio
async def test_logout_without_auth_returns_error(self, client, mock_redis):
"""验证未登录登出返回错误。"""
response = await client.post("/auth/logout")
# 未鉴权应该返回 401 或 403
assert response.status_code in (401, 403)
# --------------------------------------------------------------------------
# 4. GET /auth/me — 获取当前用户信息
# --------------------------------------------------------------------------
class TestCurrentUserEndpoint:
"""测试统一认证的获取当前用户信息端点。"""
@pytest.mark.asyncio
async def test_get_current_user_returns_info(self, client, mock_redis):
"""验证获取当前用户信息成功。"""
# 创建有效 token
token = await _create_test_token(
mock_redis,
employee_id="test-user-001",
name="测试用户",
roles=["agent"],
login_source="agent",
)
# 获取当前用户
response = await client.get(
"/auth/me",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
body = response.json()
assert body["code"] == 0
data = body["data"]
assert data["employee_id"] == "test-user-001"
assert data["name"] == "测试用户"
assert data["roles"] == ["agent"]
assert data["current_role"] == "agent"
assert data["login_source"] == "agent"
@pytest.mark.asyncio
async def test_get_current_user_without_auth_returns_error(self, client, mock_redis):
"""验证未登录获取用户信息返回错误。"""
response = await client.get("/auth/me")
# 未鉴权应该返回 401 或 403
assert response.status_code in (401, 403)
# --------------------------------------------------------------------------
# 5. POST /auth/switch-role — 切换角色
# --------------------------------------------------------------------------
class TestSwitchRoleEndpoint:
"""测试统一认证的角色切换端点。"""
@pytest.mark.asyncio
async def test_switch_role_without_auth_returns_error(self, client, mock_redis):
"""验证未登录切换角色返回错误。"""
response = await client.post(
"/auth/switch-role",
json={"role": "admin"},
)
# 未鉴权应该返回 401 或 403
assert response.status_code in (401, 403)
# --------------------------------------------------------------------------
# 6. 废弃 API 测试
# --------------------------------------------------------------------------
class TestDeprecatedAPIs:
"""测试废弃的登录 API 是否正确禁用或返回提示。"""
@pytest.mark.asyncio
async def test_agents_login_still_works(self, client, mock_redis, db_session):
"""验证坐席登录端点仍然可用。"""
# agents/login 是坐席登录,仍应可用
response = await client.post(
"/agents/login",
json={"user_id": "test-agent-001", "name": "测试坐席"},
)
# 应该返回 200
assert response.status_code == 200
body = response.json()
assert body["code"] == 0
assert "token" in body["data"]
+841
View File
@@ -0,0 +1,841 @@
# -*- coding: utf-8 -*-
"""Bug 修复回归测试 — 知识迭代模块 (Bug #8 / #7 / #6)
Bug #8: 新增 POST /admin/knowledge-iteration/suggestions 端点(手动创建知识建议)
Bug #7: Neo4j create_relation 中 CREATE → MERGE(关系创建幂等化)
Bug #6: 新增 expire_pending_suggestions 定时任务(72 小时过期 pending 建议)
测试依赖: conftest.py 提供的 client / db_session / mock_redis fixtures
"""
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select, text as sa_text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.models.knowledge_suggestion import KnowledgeSuggestion
from app.models.neo4j_schema import IssueNode, ActionNode, RelationEdge
# ============================================================================
# 辅助函数 — 管理员/普通用户登录(复用 test_tier1_api.py 模式)
# ============================================================================
async def _login_admin(client: AsyncClient, db_session: AsyncSession) -> str:
"""创建 admin 角色用户并返回 Bearer token。"""
from app.models.role import Role
from app.models.user_role import UserRole
admin_id = f"test_admin_{uuid.uuid4().hex[:8]}"
# 1. 确保 admin 角色存在
stmt = select(Role).where(Role.name == "admin")
result = await db_session.execute(stmt)
admin_role = result.scalars().first()
if not admin_role:
admin_role = Role(
name="admin", display_name="管理员",
description="系统管理员", permissions=[],
)
db_session.add(admin_role)
await db_session.flush()
# 2. 创建 UserRole 关联
ur_stmt = select(UserRole).where(
UserRole.employee_id == admin_id,
UserRole.role_id == admin_role.id,
)
ur_result = await db_session.execute(ur_stmt)
if not ur_result.scalars().first():
db_session.add(UserRole(
employee_id=admin_id, role_id=admin_role.id,
source="manual", assigned_by="test_fixture",
))
await db_session.flush()
# 3. 登录
resp = await client.post("/agents/login", json={
"user_id": admin_id, "name": "测试管理员",
})
data = resp.json()
return data["data"]["token"]
async def _login_any_user(client: AsyncClient, db_session: AsyncSession) -> str:
"""创建普通用户并返回 Bearer token(用于权限测试)。"""
from app.models.role import Role
from app.models.user_role import UserRole
user_id = f"test_user_{uuid.uuid4().hex[:8]}"
# 确保 user 角色存在
stmt = select(Role).where(Role.name == "user")
result = await db_session.execute(stmt)
user_role = result.scalars().first()
if not user_role:
user_role = Role(
name="user", display_name="普通用户",
description="普通员工", permissions=[],
)
db_session.add(user_role)
await db_session.flush()
# 创建 UserRole 关联(不含 admin 角色)
ur_stmt = select(UserRole).where(
UserRole.employee_id == user_id,
UserRole.role_id == user_role.id,
)
ur_result = await db_session.execute(ur_stmt)
if not ur_result.scalars().first():
db_session.add(UserRole(
employee_id=user_id, role_id=user_role.id,
source="manual", assigned_by="test_fixture",
))
await db_session.flush()
# 登录
resp = await client.post("/agents/login", json={
"user_id": user_id, "name": "测试用户",
})
data = resp.json()
return data["data"]["token"]
# ============================================================================
# Section A — Bug #8: POST /admin/knowledge-iteration/suggestions
# ============================================================================
class TestCreateSuggestion:
"""测试手动创建知识建议端点(Bug #8 修复)。"""
@pytest.mark.asyncio
async def test_create_suggestion_success(self, client: AsyncClient, db_session: AsyncSession):
"""正常创建建议,验证返回 code=0、status=pending、数据字段正确。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "VPN 连接失败怎么办",
"content": "1. 检查网络 2. 重启 VPN 客户端 3. 联系 IT 支持",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert "创建成功" in data["message"]
suggestion = data["data"]
assert suggestion["status"] == "pending"
assert suggestion["title"] == "VPN 连接失败怎么办"
assert suggestion["content"] == "1. 检查网络 2. 重启 VPN 客户端 3. 联系 IT 支持"
assert suggestion["source_type"] == "manual"
assert suggestion["suggestion_type"] == "new_faq"
assert suggestion["id"] # UUID 非空
assert suggestion["graph_sync_status"] == "pending"
@pytest.mark.asyncio
async def test_create_suggestion_missing_title(self, client: AsyncClient, db_session: AsyncSession):
"""缺少 title 字段 — Pydantic 校验拒绝(422)。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"content": "测试内容",
"source_type": "manual",
# 缺少 title
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
# Pydantic 对缺失的必填字段返回 422
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_suggestion_missing_content(self, client: AsyncClient, db_session: AsyncSession):
"""缺少 content 字段 — Pydantic 校验拒绝(422)。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "测试标题",
"source_type": "manual",
# 缺少 content
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_suggestion_missing_source_type(self, client: AsyncClient, db_session: AsyncSession):
"""缺少 source_type 字段 — Pydantic 校验拒绝(422)。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "测试标题",
"content": "测试内容",
# 缺少 source_type
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_suggestion_empty_title(self, client: AsyncClient, db_session: AsyncSession):
"""title 为空字符串 — 业务校验返回 code=400。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "",
"content": "测试内容",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 400
assert "标题" in data["message"]
@pytest.mark.asyncio
async def test_create_suggestion_empty_content(self, client: AsyncClient, db_session: AsyncSession):
"""content 为空字符串 — 业务校验返回 code=400。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "测试标题",
"content": "",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 400
assert "内容" in data["message"]
@pytest.mark.asyncio
async def test_create_suggestion_whitespace_title(self, client: AsyncClient, db_session: AsyncSession):
"""title 为纯空格 — strip 后为空,业务校验返回 code=400。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": " ",
"content": "测试内容",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 400
@pytest.mark.asyncio
async def test_create_suggestion_with_optional_fields(self, client: AsyncClient, db_session: AsyncSession):
"""带可选字段(audience、issue、action、relation_type)创建成功。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "带图字段的知识建议",
"content": "包含 audience、issue、action、relation_type 等可选字段",
"source_type": "manual",
"category": "网络",
"tags": ["VPN", "网络"],
"confidence": 0.88,
"audience": "employee_quick_reply",
"issue": "VPN 连接问题",
"action": "重启 VPN 客户端",
"relation_type": "LEADS_TO",
"parent_issue": "网络故障",
"graph_meta": {"source": "test"},
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
suggestion = data["data"]
assert suggestion["status"] == "pending"
assert suggestion["audience"] == "employee_quick_reply"
assert suggestion["issue"] == "VPN 连接问题"
assert suggestion["action"] == "重启 VPN 客户端"
assert suggestion["relation_type"] == "LEADS_TO"
assert suggestion["parent_issue"] == "网络故障"
assert suggestion["confidence"] == 0.88
assert suggestion["category"] == "网络"
assert "VPN" in suggestion["tags"]
@pytest.mark.asyncio
async def test_create_suggestion_requires_admin(self, client: AsyncClient, db_session: AsyncSession):
"""非管理员请求被拒(403)。"""
token = await _login_any_user(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "权限测试",
"content": "测试内容",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_create_suggestion_no_auth(self, client: AsyncClient):
"""未携带 Token — 401/403。"""
body = {
"suggestion_type": "new_faq",
"title": "无认证测试",
"content": "测试内容",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
)
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
async def test_create_suggestion_default_category(self, client: AsyncClient, db_session: AsyncSession):
"""不传 category 时使用默认值 "其他""""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "update",
"title": "默认分类测试",
"content": "不传 category 字段",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["category"] == "其他"
# ============================================================================
# Section B — Bug #7: Neo4j create_relation MERGE 幂等性
# ============================================================================
class TestCreateRelationMerge:
"""测试 create_relation 使用 MERGE 替代 CREATEBug #7 修复)。"""
@pytest.mark.asyncio
async def test_create_relation_uses_merge(self):
"""验证 create_relation 生成的 Cypher 语句包含 MERGE 而非 CREATE。"""
from app.services.neo4j_client import Neo4jClient
# 用 __new__ 绕过 __init__,避免连接真实 Neo4j
client = Neo4jClient.__new__(Neo4jClient)
client._driver = None
# Mock execute_write_query 捕获 Cypher 语句
captured_cyphers = []
async def _capture_write(cypher, params=None):
captured_cyphers.append(cypher)
return [{"created": 1}]
client.execute_write_query = _capture_write
rel = RelationEdge(
from_uuid="uuid-from-001",
to_uuid="uuid-to-001",
type="LEADS_TO",
order=1,
weight=0.9,
)
await client.create_relation("uuid-from-001", "uuid-to-001", rel)
assert len(captured_cyphers) == 1
cypher = captured_cyphers[0]
# 核心断言:Cypher 必须使用 MERGE 而非 CREATE
assert "MERGE" in cypher, f"Cypher 应包含 MERGE,实际: {cypher}"
assert "CREATE" not in cypher.replace("CREATE", "__X__", 0) or "MERGE" in cypher, \
f"Cypher 不应使用 CREATE 创建关系,实际: {cypher}"
# 更精确的检查:不应有独立的 CREATE 关系语句
# CREATE CONSTRAINT 等不含在此 Cypher 中,所以直接检查)
assert "MERGE (from_node)-[" in cypher, f"应使用 MERGE 创建关系边,实际: {cypher}"
@pytest.mark.asyncio
async def test_create_relation_idempotent(self):
"""调用 create_relation 两次相同参数,验证 Cypher 语句一致(MERGE 幂等)。
MERGE 语义:相同参数重复执行不会创建重复边。
本测试通过验证两次调用生成相同的 MERGE Cypher 来确认幂等性。
"""
from app.services.neo4j_client import Neo4jClient
client = Neo4jClient.__new__(Neo4jClient)
client._driver = None
captured_cyphers = []
call_count = {"value": 0}
async def _capture_write(cypher, params=None):
captured_cyphers.append((cypher, params))
call_count["value"] += 1
# MERGE 幂等:两次调用都返回成功
return [{"created": 1}]
client.execute_write_query = _capture_write
rel = RelationEdge(
from_uuid="uuid-from-002",
to_uuid="uuid-to-002",
type="RELATES_TO",
order=2,
weight=0.5,
)
# 第一次调用
result1 = await client.create_relation("uuid-from-002", "uuid-to-002", rel)
# 第二次调用(相同参数)
result2 = await client.create_relation("uuid-from-002", "uuid-to-002", rel)
assert result1 is True
assert result2 is True
# 两次调用的 Cypher 应完全相同(MERGE 幂等语义)
assert len(captured_cyphers) == 2
cypher1, params1 = captured_cyphers[0]
cypher2, params2 = captured_cyphers[1]
assert cypher1 == cypher2, "两次调用的 Cypher 语句应相同"
assert params1 == params2, "两次调用的参数应相同"
# 确认使用 MERGE
assert "MERGE" in cypher1
@pytest.mark.asyncio
async def test_create_relation_different_types(self):
"""验证不同关系类型生成的 Cypher 都使用 MERGE。"""
from app.services.neo4j_client import Neo4jClient
client = Neo4jClient.__new__(Neo4jClient)
client._driver = None
captured_cyphers = []
async def _capture_write(cypher, params=None):
captured_cyphers.append(cypher)
return [{"created": 1}]
client.execute_write_query = _capture_write
for rel_type in ["LEADS_TO", "RELATES_TO", "CAN_JUMP_TO"]:
rel = RelationEdge(
from_uuid=f"from-{rel_type}",
to_uuid=f"to-{rel_type}",
type=rel_type,
order=1,
weight=1.0,
)
await client.create_relation(f"from-{rel_type}", f"to-{rel_type}", rel)
assert len(captured_cyphers) == 3
for cypher in captured_cyphers:
assert "MERGE" in cypher, f"所有关系类型都应使用 MERGE,实际: {cypher}"
# ============================================================================
# Section C — Bug #6: expire_pending_suggestions 定时任务
# ============================================================================
def _sqlite_compatible_text(sql_string):
"""将 PostgreSQL 方言 SQL 翻译为 SQLite 兼容 SQL。
expire_pending_suggestions() 内部使用 NOW() 和 INTERVAL '72 hours'
这些是 PostgreSQL 专属语法。测试使用 SQLite 内存数据库,
需要翻译为 datetime('now') 和 datetime('now', '-72 hours')。
"""
sqlite_sql = (
sql_string
.replace("NOW() - INTERVAL '72 hours'", "datetime('now', '-72 hours')")
.replace("NOW()", "datetime('now')")
)
return sa_text(sqlite_sql)
def _utcnow():
"""返回 UTC naive datetime,与 SQLite datetime('now') 时区一致。
_utcnow() 返回本地时间(如 UTC+8),而 SQLite datetime('now') 返回 UTC。
若用本地时间设置 created_atWHERE created_at < datetime('now', '-72 hours')
会因时区偏移导致匹配失败。测试中统一使用 UTC 时间避免此问题。
"""
return datetime.now(timezone.utc).replace(tzinfo=None)
class TestExpirePendingSuggestions:
"""测试 expire_pending_suggestions 定时任务(Bug #6 修复)。"""
@pytest.mark.asyncio
async def test_expire_pending_suggestions(self, db_session: AsyncSession):
"""超过 72 小时的 pending 建议被标记为 expired。"""
from app.main import expire_pending_suggestions
# 创建一条 created_at 超过 72 小时的 pending 建议
# 注意: 使用 UTC 时间(_utcnow)而非 datetime.now(),因为 SQLite 的
# datetime('now') 返回 UTC,而 datetime.now() 返回本地时间(UTC+8),
# 时区不一致会导致 WHERE 条件匹配失败
old_time = _utcnow() - timedelta(hours=73)
suggestion = KnowledgeSuggestion(
suggestion_type="new_faq",
status="pending",
title="过期测试建议",
content="这条建议创建超过 72 小时,应被过期",
category="网络",
tags=["测试"],
source_type="conversation",
source_data=["conv-old-001"],
reason="过期测试",
confidence=0.8,
audience="employee_quick_reply",
created_at=old_time,
)
db_session.add(suggestion)
await db_session.commit()
# 构建与 db_session 同引擎的 session factoryStaticPool 共享连接)
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
# Patch _get_session_factory 返回测试 session factory
# Patch text 为 SQLite 兼容版本
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
# 刷新 session 缓存并查询
suggestion_id = suggestion.id
db_session.expire_all()
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
assert updated.status == "expired", \
f"超时 pending 建议应变为 expired,实际: {updated.status}"
@pytest.mark.asyncio
async def test_expire_does_not_affect_recent(self, db_session: AsyncSession):
"""新创建的 pending 建议(created_at 为当前时间)不受影响。"""
from app.main import expire_pending_suggestions
# 创建一条新的 pending 建议(created_at 为当前时间)
suggestion = KnowledgeSuggestion(
suggestion_type="new_faq",
status="pending",
title="新建待审建议",
content="这条建议刚创建,不应被过期",
category="软件",
tags=["测试"],
source_type="manual",
source_data=None,
reason="近期测试",
confidence=0.75,
audience="employee_quick_reply",
created_at=_utcnow(),
)
db_session.add(suggestion)
await db_session.commit()
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
suggestion_id = suggestion.id
db_session.expire_all()
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
assert updated.status == "pending", \
f"近期 pending 建议不应被过期,实际: {updated.status}"
@pytest.mark.asyncio
async def test_expire_does_not_affect_queued(self, db_session: AsyncSession):
"""queued 状态的建议(即使超过 72 小时)不被过期。"""
from app.main import expire_pending_suggestions
old_time = _utcnow() - timedelta(hours=73)
suggestion = KnowledgeSuggestion(
suggestion_type="update",
status="queued",
title="队列中的旧建议",
content="这条建议在队列中,即使超过 72 小时也不应被过期",
category="账号",
tags=["队列", "测试"],
source_type="conversation",
source_data=["conv-queued-old"],
reason="队列过期测试",
confidence=0.6,
audience="engineer_workguide",
created_at=old_time,
)
db_session.add(suggestion)
await db_session.commit()
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
suggestion_id = suggestion.id
db_session.expire_all()
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
assert updated.status == "queued", \
f"queued 建议不应被过期,实际: {updated.status}"
@pytest.mark.asyncio
async def test_expire_does_not_affect_approved(self, db_session: AsyncSession):
"""approved 状态的建议(超过 72 小时)不被过期。"""
from app.main import expire_pending_suggestions
old_time = _utcnow() - timedelta(hours=73)
suggestion = KnowledgeSuggestion(
suggestion_type="new_faq",
status="approved",
title="已通过的旧建议",
content="这条建议已通过审核,不应被过期",
category="安全",
tags=["审批", "测试"],
source_type="annotation",
source_data=["annot-old-001"],
reason="审批过期测试",
confidence=0.9,
audience="employee_quick_reply",
created_at=old_time,
)
db_session.add(suggestion)
await db_session.commit()
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
suggestion_id = suggestion.id
db_session.expire_all()
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
assert updated.status == "approved", \
f"approved 建议不应被过期,实际: {updated.status}"
@pytest.mark.asyncio
async def test_expire_does_not_affect_rejected(self, db_session: AsyncSession):
"""rejected 状态的建议(超过 72 小时)不被过期。"""
from app.main import expire_pending_suggestions
old_time = _utcnow() - timedelta(hours=73)
suggestion = KnowledgeSuggestion(
suggestion_type="outdated",
status="rejected",
title="已拒绝的旧建议",
content="这条建议已被拒绝,不应被过期",
category="硬件",
tags=["拒绝", "测试"],
source_type="ai_uncertain",
source_data=None,
reason="拒绝过期测试",
confidence=0.3,
audience="engineer_workguide",
reject_reason="内容不准确",
created_at=old_time,
)
db_session.add(suggestion)
await db_session.commit()
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
suggestion_id = suggestion.id
db_session.expire_all()
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
assert updated.status == "rejected", \
f"rejected 建议不应被过期,实际: {updated.status}"
@pytest.mark.asyncio
async def test_expire_multiple_pending_only(self, db_session: AsyncSession):
"""混合状态下只有超时 pending 被过期,其他状态不受影响。"""
from app.main import expire_pending_suggestions
old_time = _utcnow() - timedelta(hours=80)
# 创建多条不同状态的建议,created_at 均超过 72 小时
suggestions = []
for status in ["pending", "queued", "approved", "rejected", "pending"]:
s = KnowledgeSuggestion(
suggestion_type="new_faq",
status=status,
title=f"混合测试-{status}",
content=f"状态 {status} 的旧建议",
category="网络",
tags=["混合测试"],
source_type="conversation",
source_data=[f"conv-mix-{status}"],
reason="混合状态测试",
confidence=0.7,
audience="employee_quick_reply",
created_at=old_time,
)
suggestions.append(s)
db_session.add(s)
await db_session.commit()
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
# 在 expire_all 之前捕获 id 和原始 status,避免 expire 后触发同步懒加载
captured = [(s.id, s.status) for s in suggestions]
db_session.expire_all()
# 验证:只有 pending → expired,其他状态不变
for sid, orig_status in captured:
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == sid)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
if orig_status == "pending":
assert updated.status == "expired", \
f"pending 应变为 expired,实际: {updated.status}"
else:
assert updated.status == orig_status, \
f"{orig_status} 状态不应改变,实际: {updated.status}"
@pytest.mark.asyncio
async def test_expire_boundary_72_hours(self, db_session: AsyncSession):
"""边界测试:created_at 恰好 71 小时(未超 72 小时)的 pending 不被过期。"""
from app.main import expire_pending_suggestions
# 71 小时前 — 未达到 72 小时阈值
boundary_time = _utcnow() - timedelta(hours=71)
suggestion = KnowledgeSuggestion(
suggestion_type="new_faq",
status="pending",
title="边界测试建议",
content="created_at 71 小时前,不应被过期",
category="软件",
tags=["边界"],
source_type="manual",
source_data=None,
reason="边界测试",
confidence=0.8,
audience="employee_quick_reply",
created_at=boundary_time,
)
db_session.add(suggestion)
await db_session.commit()
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
suggestion_id = suggestion.id
db_session.expire_all()
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
# 71 小时 < 72 小时,不应被过期
assert updated.status == "pending", \
f"71 小时的 pending 不应被过期,实际: {updated.status}"
+834
View File
@@ -0,0 +1,834 @@
# =============================================================================
# IT智能服务台 — 自备电脑补贴(BYOD)资格查询 API 测试
# =============================================================================
# 测试覆盖:
# 1. 岗位匹配逻辑 _match_position(精确/包含/关键词/不匹配/空字符串/全部13岗位)
# 2. 关键词预过滤 _byod_keyword_prefilter(命中/未命中/空字符串)
# 3. 关键词兜底 _byod_fallback_detect(命中/未命中/空字符串)
# 4. API 端点 /byod/detect-intent(关键词命中→fallback / 关键词未命中→prefilter
# 5. API 端点 /byod/check-eligibility(空ID/有资格/无资格/企微异常/空岗位)
# 6. API 端点 /byod/eligible-positions13岗位/结构/URL/notes
# 7. 数据文件 byod_eligible_positions.json 验证
#
# 响应格式:{code: 0, data: {...}, message: "success"}success_response 包装)
# =============================================================================
import json
import os
from unittest.mock import AsyncMock, patch
import pytest
from app.api.byod import (
BYOD_APPLICATION_URL,
BYOD_ELIGIBLE_POSITIONS,
BYOD_NOTES,
BYOD_POSITION_MATCH_KEYWORDS,
BYOD_PREFILTER_KEYWORDS,
_byod_fallback_detect,
_byod_keyword_prefilter,
_match_position,
)
from app.config import settings
# =============================================================================
# 辅助:收集全部 13 个资格岗位(用于参数化测试)
# =============================================================================
ALL_ELIGIBLE_POSITIONS: list[tuple[str, str, str]] = []
for _seq, _cats in BYOD_ELIGIBLE_POSITIONS.items():
for _cat, _positions in _cats.items():
for _pos in _positions:
ALL_ELIGIBLE_POSITIONS.append((_pos, _seq, _cat))
# =============================================================================
# 单元测试:_match_position
# =============================================================================
class TestMatchPosition:
"""测试岗位匹配逻辑 _match_position。"""
def test_exact_match(self):
"""精确匹配:position 与资格岗位完全一致 → 匹配成功。"""
matched, pos, category = _match_position("前端开发岗")
assert matched is True
assert pos == "前端开发岗"
assert category == "技术序列 - 开发类"
def test_contains_match(self):
"""包含匹配:资格岗位是员工岗位的子串 → 匹配成功。"""
matched, pos, category = _match_position("高级前端开发岗")
assert matched is True
assert pos == "前端开发岗"
assert category == "技术序列 - 开发类"
def test_keyword_match(self):
"""关键词匹配:员工岗位包含核心关键词 → 匹配成功。"""
# "前端工程师" 不包含 "前端开发岗" 也不包含 "前端开发"
# 但包含关键词 "前端"
matched, pos, category = _match_position("前端工程师")
assert matched is True
assert pos == "前端开发岗"
assert category == "技术序列 - 开发类"
def test_no_match_it_support(self):
"""不匹配:IT支持组组长 → 匹配失败。"""
matched, pos, category = _match_position("IT支持组组长")
assert matched is False
assert pos == ""
assert category == ""
def test_no_match_sales(self):
"""不匹配:销售经理 → 匹配失败。"""
matched, pos, category = _match_position("销售经理")
assert matched is False
assert pos == ""
assert category == ""
def test_empty_string(self):
"""空字符串 → 匹配失败。"""
matched, pos, category = _match_position("")
assert matched is False
assert pos == ""
assert category == ""
def test_whitespace_only(self):
"""纯空白字符串 → 匹配失败。"""
matched, pos, category = _match_position(" ")
assert matched is False
assert pos == ""
assert category == ""
def test_stripped_match(self):
"""带前后空格的岗位 → 去空格后匹配成功。"""
matched, pos, category = _match_position(" 前端开发岗 ")
assert matched is True
assert pos == "前端开发岗"
assert category == "技术序列 - 开发类"
@pytest.mark.parametrize("position,sequence,category", ALL_ELIGIBLE_POSITIONS)
def test_all_eligible_positions_exact_match(self, position, sequence, category):
"""全部 13 个资格岗位逐一精确匹配测试。"""
matched, matched_pos, matched_cat = _match_position(position)
assert matched is True, f"岗位 '{position}' 应匹配但未匹配"
assert matched_pos == position
assert matched_cat == f"{sequence} - {category}"
def test_keyword_match_ux_design(self):
"""关键词匹配:用户体验设计岗的多种关键词变体。"""
# "UX设计师" 包含关键词 "UX设计"
matched, pos, _ = _match_position("UX设计师")
assert matched is True
assert pos == "用户体验设计岗"
# "交互设计专家" 包含关键词 "交互设计"
matched, pos, _ = _match_position("交互设计专家")
assert matched is True
assert pos == "用户体验设计岗"
def test_keyword_match_mobile(self):
"""关键词匹配:移动端开发岗的多种关键词变体。"""
# "移动开发组长" 包含关键词 "移动开发"
matched, pos, _ = _match_position("移动开发组长")
assert matched is True
assert pos == "移动端开发岗"
def test_no_match_partial_keyword(self):
"""不匹配:仅包含部分关键词(如""不匹配"客户端")。"""
matched, _, _ = _match_position("服务端架构师")
assert matched is False
def test_return_type_is_tuple(self):
"""返回值类型为三元组 (bool, str, str)。"""
result = _match_position("前端开发岗")
assert isinstance(result, tuple)
assert len(result) == 3
assert isinstance(result[0], bool)
assert isinstance(result[1], str)
assert isinstance(result[2], str)
# =============================================================================
# 单元测试:_byod_keyword_prefilter
# =============================================================================
class TestByodKeywordPrefilter:
"""测试 BYOD 关键词预过滤函数。"""
def test_hit_zibei_diannao(self):
"""包含 '自备电脑' 关键词 → True。"""
assert _byod_keyword_prefilter("我想申请自备电脑补贴") is True
def test_hit_byod_uppercase(self):
"""包含 'BYOD' 关键词(大写)→ True。"""
assert _byod_keyword_prefilter("BYOD政策是什么") is True
def test_hit_byod_lowercase(self):
"""包含 'byod' 关键词(小写)→ True(大小写不敏感)。"""
assert _byod_keyword_prefilter("byod政策是什么") is True
def test_hit_diannao_butie(self):
"""包含 '电脑补贴' 关键词 → True。"""
assert _byod_keyword_prefilter("电脑补贴资格") is True
def test_hit_butie_zige(self):
"""包含 '补贴资格' 关键词 → True。"""
assert _byod_keyword_prefilter("我有补贴资格吗") is True
def test_miss_normal_message(self):
"""普通消息不包含 BYOD 关键词 → False。"""
assert _byod_keyword_prefilter("打印机坏了") is False
def test_miss_empty_string(self):
"""空字符串 → False。"""
assert _byod_keyword_prefilter("") is False
def test_miss_unrelated_it_message(self):
"""IT 相关但非 BYOD 的消息 → False。"""
assert _byod_keyword_prefilter("VPN连不上了") is False
assert _byod_keyword_prefilter("我的电脑黑屏了") is False
def test_case_insensitive(self):
"""关键词匹配大小写不敏感。"""
assert _byod_keyword_prefilter("Byod") is True
assert _byod_keyword_prefilter("BYOD") is True
assert _byod_keyword_prefilter("byod") is True
def test_all_prefilter_keywords_work(self):
"""验证 BYOD_PREFILTER_KEYWORDS 中的每个关键词都能被命中。"""
for kw in BYOD_PREFILTER_KEYWORDS:
text = f"测试文本包含{kw}关键词"
assert _byod_keyword_prefilter(text) is True, f"关键词 '{kw}' 未被命中"
# =============================================================================
# 单元测试:_byod_fallback_detect
# =============================================================================
class TestByodFallbackDetect:
"""测试 BYOD 关键词兜底检测函数。"""
def test_fallback_hit_returns_true(self):
"""包含 BYOD 关键词的文本 → 兜底返回 True。"""
is_byod, confidence = _byod_fallback_detect("我想申请自备电脑补贴")
assert is_byod is True
assert confidence == 0.6
def test_fallback_hit_byod_keyword(self):
"""包含 BYOD 关键词 → True。"""
is_byod, _ = _byod_fallback_detect("BYOD政策")
assert is_byod is True
def test_fallback_miss_normal_message(self):
"""普通消息不包含 BYOD 关键词 → False。"""
is_byod, confidence = _byod_fallback_detect("打印机坏了")
assert is_byod is False
assert confidence == 0.0
def test_fallback_empty_string(self):
"""空字符串 → False。"""
is_byod, confidence = _byod_fallback_detect("")
assert is_byod is False
assert confidence == 0.0
def test_fallback_none_text(self):
"""None → False(安全处理)。"""
is_byod, confidence = _byod_fallback_detect(None) # type: ignore[arg-type]
assert is_byod is False
assert confidence == 0.0
def test_fallback_confidence_is_0_6(self):
"""兜底置信度固定为 0.6。"""
_, confidence = _byod_fallback_detect("自备电脑补贴怎么申请")
assert confidence == 0.6
# =============================================================================
# API 端点测试:POST /byod/detect-intent
# =============================================================================
# 响应格式:{code: 0, data: {is_byod_intent, eligible, source, ...}, message: "success"}
# Dify 未配置时:关键词命中 → _call_dify_byod_intent 抛 ValueError → 降级兜底 source=fallback
# =============================================================================
class TestDetectByodIntentEndpoint:
"""测试 /byod/detect-intent API 端点。"""
@pytest.mark.asyncio
async def test_byod_keyword_hit_fallback(self, client):
"""包含 BYOD 关键词 + Dify 未配置 → is_byod_intent=True, source=fallback。"""
# Dify 未配置(approval_dify_base_url 为空),会触发降级兜底
response = await client.post(
"/byod/detect-intent",
json={"text": "我想申请自备电脑补贴"},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["message"] == "success"
inner = data["data"]
assert inner["is_byod_intent"] is True
assert inner["source"] == "fallback"
@pytest.mark.asyncio
async def test_byod_keyword_hit_byod_text(self, client):
"""包含 'BYOD' 关键词 + Dify 未配置 → is_byod_intent=True, source=fallback。"""
response = await client.post(
"/byod/detect-intent",
json={"text": "BYOD政策是什么"},
)
assert response.status_code == 200
data = response.json()
inner = data["data"]
assert inner["is_byod_intent"] is True
assert inner["source"] == "fallback"
@pytest.mark.asyncio
async def test_keyword_not_hit_returns_false(self, client):
"""不包含 BYOD 关键词 → is_byod_intent=False, source=keyword_prefilter。"""
response = await client.post(
"/byod/detect-intent",
json={"text": "打印机坏了,帮我修一下"},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
inner = data["data"]
assert inner["is_byod_intent"] is False
assert inner["source"] == "keyword_prefilter"
@pytest.mark.asyncio
async def test_empty_text_returns_false(self, client):
"""空文本 → 关键词未命中, is_byod_intent=False。"""
response = await client.post(
"/byod/detect-intent",
json={"text": ""},
)
assert response.status_code == 200
data = response.json()
inner = data["data"]
assert inner["is_byod_intent"] is False
assert inner["source"] == "keyword_prefilter"
@pytest.mark.asyncio
async def test_dify_configured_high_confidence(self, client, monkeypatch):
"""Dify 已配置 + 高置信度 → is_byod_intent=True, source=dify。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
mock_dify = AsyncMock(return_value={
"is_byod_intent": True,
"confidence": 0.95,
})
with patch("app.api.byod._call_dify_byod_intent", mock_dify):
response = await client.post(
"/byod/detect-intent",
json={"text": "我想申请自备电脑补贴"},
)
assert response.status_code == 200
data = response.json()
inner = data["data"]
assert inner["is_byod_intent"] is True
assert inner["source"] == "dify"
mock_dify.assert_called_once()
@pytest.mark.asyncio
async def test_dify_failure_fallback(self, client, monkeypatch):
"""Dify 已配置但调用失败 → 降级兜底, source=fallback。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
mock_dify = AsyncMock(side_effect=Exception("Dify 服务不可达"))
with patch("app.api.byod._call_dify_byod_intent", mock_dify):
response = await client.post(
"/byod/detect-intent",
json={"text": "自备电脑补贴怎么申请"},
)
assert response.status_code == 200
data = response.json()
inner = data["data"]
assert inner["is_byod_intent"] is True
assert inner["source"] == "fallback"
@pytest.mark.asyncio
async def test_dify_low_confidence_returns_false(self, client, monkeypatch):
"""Dify 已配置 + 低置信度 → is_byod_intent=False, source=dify。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
mock_dify = AsyncMock(return_value={
"is_byod_intent": True,
"confidence": 0.5,
})
with patch("app.api.byod._call_dify_byod_intent", mock_dify):
response = await client.post(
"/byod/detect-intent",
json={"text": "电脑补贴资格"},
)
data = response.json()
inner = data["data"]
assert inner["is_byod_intent"] is False
assert inner["source"] == "dify"
@pytest.mark.asyncio
async def test_response_has_all_required_fields(self, client):
"""验证响应包含 ByodEligibilityResponse 的所有必需字段。"""
response = await client.post(
"/byod/detect-intent",
json={"text": "你好"},
)
data = response.json()
inner = data["data"]
assert "is_byod_intent" in inner
assert "eligible" in inner
assert "position" in inner
assert "matched_category" in inner
assert "application_url" in inner
assert "notes" in inner
assert "reason" in inner
assert "source" in inner
@pytest.mark.asyncio
async def test_employee_id_passed_to_dify(self, client, monkeypatch):
"""验证 employee_id 被传递给 Dify 调用。"""
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
mock_dify = AsyncMock(return_value={
"is_byod_intent": True,
"confidence": 0.9,
})
with patch("app.api.byod._call_dify_byod_intent", mock_dify):
await client.post(
"/byod/detect-intent",
json={"text": "自备电脑补贴", "employee_id": "test_emp_001"},
)
mock_dify.assert_called_once_with("自备电脑补贴", "test_emp_001")
# =============================================================================
# API 端点测试:POST /byod/check-eligibility
# =============================================================================
# 需要 mock Redisget_redis 从 app.main 导入 redis_client)和 WecomService
# conftest 已 patch app.services.wecom_service.WecomService,但 byod.py 在模块
# 加载时通过 `from app.services.wecom_service import WecomService` 获得了自己的
# 引用,因此必须额外 patch app.api.byod.WecomService 才能让 mock 生效。
# =============================================================================
@pytest.fixture
def byod_wecom_mock(mock_wecom_instance):
"""Patch app.api.byod.WecomService 并保存/恢复 get_user_info 的 mock 状态。
conftest 在模块级设置了 mock_wecom_module.get_user_info.side_effect
BYOD 测试需要临时覆盖它以返回不同岗位,测试后恢复原值。
同时 patch byod 模块中的 WecomService 引用(conftest 未覆盖此模块)。
"""
original_se = mock_wecom_instance.get_user_info.side_effect
original_rv = mock_wecom_instance.get_user_info.return_value
# patch byod.py 模块中的 WecomService 引用(from ... import WecomService
with patch("app.api.byod.WecomService", return_value=mock_wecom_instance):
yield mock_wecom_instance
mock_wecom_instance.get_user_info.side_effect = original_se
mock_wecom_instance.get_user_info.return_value = original_rv
class TestCheckEligibilityEndpoint:
"""测试 /byod/check-eligibility API 端点。"""
@pytest.mark.asyncio
async def test_empty_employee_id(self, client, mock_redis):
"""employee_id 为空 → eligible=False, reason 包含 '缺少员工ID'"""
with patch("app.main.redis_client", mock_redis, create=True):
response = await client.post(
"/byod/check-eligibility",
json={"employee_id": ""},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
inner = data["data"]
assert inner["eligible"] is False
assert "缺少员工ID" in inner["reason"]
assert inner["source"] == "wecom"
@pytest.mark.asyncio
async def test_eligible_position(self, client, mock_redis, byod_wecom_mock):
"""岗位在资格清单中 → eligible=True, 返回申请链接和注意事项。"""
byod_wecom_mock.get_user_info.side_effect = None
byod_wecom_mock.get_user_info.return_value = {
"position": "前端开发岗",
"name": "张三",
}
with patch("app.main.redis_client", mock_redis, create=True):
response = await client.post(
"/byod/check-eligibility",
json={"employee_id": "zhangsan"},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
inner = data["data"]
assert inner["eligible"] is True
assert inner["position"] == "前端开发岗"
assert inner["matched_category"] == "技术序列 - 开发类"
assert inner["application_url"] == BYOD_APPLICATION_URL
assert inner["notes"] == BYOD_NOTES
assert inner["source"] == "wecom"
@pytest.mark.asyncio
async def test_eligible_position_contains_match(self, client, mock_redis, byod_wecom_mock):
"""包含匹配:岗位 '高级后端开发岗' → eligible=True。"""
byod_wecom_mock.get_user_info.side_effect = None
byod_wecom_mock.get_user_info.return_value = {
"position": "高级后端开发岗",
"name": "李四",
}
with patch("app.main.redis_client", mock_redis, create=True):
response = await client.post(
"/byod/check-eligibility",
json={"employee_id": "lisi"},
)
data = response.json()
inner = data["data"]
assert inner["eligible"] is True
assert inner["position"] == "高级后端开发岗"
assert inner["matched_category"] == "技术序列 - 开发类"
@pytest.mark.asyncio
async def test_eligible_position_keyword_match(self, client, mock_redis, byod_wecom_mock):
"""关键词匹配:岗位 '算法工程师' → eligible=True(关键词 '算法')。"""
byod_wecom_mock.get_user_info.side_effect = None
byod_wecom_mock.get_user_info.return_value = {
"position": "算法工程师",
"name": "王五",
}
with patch("app.main.redis_client", mock_redis, create=True):
response = await client.post(
"/byod/check-eligibility",
json={"employee_id": "wangwu"},
)
data = response.json()
inner = data["data"]
assert inner["eligible"] is True
assert inner["matched_category"] == "技术序列 - 开发类"
@pytest.mark.asyncio
async def test_ineligible_position(self, client, mock_redis, byod_wecom_mock):
"""岗位不在资格清单中 → eligible=False, reason 包含岗位名。"""
byod_wecom_mock.get_user_info.side_effect = None
byod_wecom_mock.get_user_info.return_value = {
"position": "销售经理",
"name": "赵六",
}
with patch("app.main.redis_client", mock_redis, create=True):
response = await client.post(
"/byod/check-eligibility",
json={"employee_id": "zhaoliu"},
)
assert response.status_code == 200
data = response.json()
inner = data["data"]
assert inner["eligible"] is False
assert inner["position"] == "销售经理"
assert "销售经理" in inner["reason"]
assert inner["application_url"] == ""
assert inner["notes"] == []
@pytest.mark.asyncio
async def test_wecom_service_failure(self, client, mock_redis, byod_wecom_mock):
"""企微 API 调用失败 → eligible=False, reason 包含错误信息。"""
async def _raise_error(user_id, **kwargs):
raise Exception("企微API不可达")
byod_wecom_mock.get_user_info.side_effect = _raise_error
with patch("app.main.redis_client", mock_redis, create=True):
response = await client.post(
"/byod/check-eligibility",
json={"employee_id": "test_emp"},
)
assert response.status_code == 200
data = response.json()
inner = data["data"]
assert inner["eligible"] is False
assert "获取员工信息失败" in inner["reason"]
assert inner["source"] == "wecom"
@pytest.mark.asyncio
async def test_empty_position(self, client, mock_redis, byod_wecom_mock):
"""企微返回空岗位 → eligible=False, reason 提示联系IT服务台。"""
byod_wecom_mock.get_user_info.side_effect = None
byod_wecom_mock.get_user_info.return_value = {
"position": "",
"name": "测试员工",
}
with patch("app.main.redis_client", mock_redis, create=True):
response = await client.post(
"/byod/check-eligibility",
json={"employee_id": "test_emp"},
)
assert response.status_code == 200
data = response.json()
inner = data["data"]
assert inner["eligible"] is False
assert "岗位信息" in inner["reason"]
assert inner["source"] == "wecom"
@pytest.mark.asyncio
async def test_product_manager_eligible(self, client, mock_redis, byod_wecom_mock):
"""产品经理岗位 → eligible=True(产品序列 - 产品策划与设计类)。"""
byod_wecom_mock.get_user_info.side_effect = None
byod_wecom_mock.get_user_info.return_value = {
"position": "产品经理",
"name": "产品",
}
with patch("app.main.redis_client", mock_redis, create=True):
response = await client.post(
"/byod/check-eligibility",
json={"employee_id": "pm001"},
)
data = response.json()
inner = data["data"]
assert inner["eligible"] is True
assert inner["matched_category"] == "产品序列 - 产品策划与设计类"
@pytest.mark.asyncio
async def test_response_has_all_required_fields(self, client, mock_redis, byod_wecom_mock):
"""验证响应包含所有必需字段。"""
byod_wecom_mock.get_user_info.side_effect = None
byod_wecom_mock.get_user_info.return_value = {
"position": "测试开发岗",
"name": "测试",
}
with patch("app.main.redis_client", mock_redis, create=True):
response = await client.post(
"/byod/check-eligibility",
json={"employee_id": "tester001"},
)
data = response.json()
inner = data["data"]
assert "is_byod_intent" in inner
assert "eligible" in inner
assert "position" in inner
assert "matched_category" in inner
assert "application_url" in inner
assert "notes" in inner
assert "reason" in inner
assert "source" in inner
# =============================================================================
# API 端点测试:GET /byod/eligible-positions
# =============================================================================
class TestEligiblePositionsEndpoint:
"""测试 /byod/eligible-positions API 端点。"""
@pytest.mark.asyncio
async def test_returns_13_positions(self, client):
"""返回 13 个岗位, total_count=13。"""
response = await client.get("/byod/eligible-positions")
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["message"] == "success"
inner = data["data"]
assert inner["total_count"] == 13
@pytest.mark.asyncio
async def test_positions_structure(self, client):
"""岗位清单结构正确:序列 → 类别 → 岗位列表。"""
response = await client.get("/byod/eligible-positions")
data = response.json()
positions = data["data"]["positions"]
# 顶级 key 应为序列名称
assert "技术序列" in positions
assert "产品序列" in positions
# 技术序列下有 3 个类别
tech = positions["技术序列"]
assert "开发类" in tech
assert "数据类" in tech
assert "测试类" in tech
# 开发类有 6 个岗位
assert len(tech["开发类"]) == 6
assert "前端开发岗" in tech["开发类"]
assert "算法岗" in tech["开发类"]
# 数据类有 2 个岗位
assert len(tech["数据类"]) == 2
# 测试类有 2 个岗位
assert len(tech["测试类"]) == 2
# 产品序列下有 1 个类别,3 个岗位
product = positions["产品序列"]
assert "产品策划与设计类" in product
assert len(product["产品策划与设计类"]) == 3
@pytest.mark.asyncio
async def test_application_url_present(self, client):
"""返回 application_url 且不为空。"""
response = await client.get("/byod/eligible-positions")
data = response.json()
inner = data["data"]
assert inner["application_url"] == BYOD_APPLICATION_URL
assert "ehr.servyou.com.cn" in inner["application_url"]
@pytest.mark.asyncio
async def test_notes_present(self, client):
"""返回 notes 列表且不为空。"""
response = await client.get("/byod/eligible-positions")
data = response.json()
inner = data["data"]
assert isinstance(inner["notes"], list)
assert len(inner["notes"]) == len(BYOD_NOTES)
assert len(inner["notes"]) > 0
@pytest.mark.asyncio
async def test_total_count_matches_actual(self, client):
"""total_count 与实际岗位数一致。"""
response = await client.get("/byod/eligible-positions")
data = response.json()
inner = data["data"]
positions = inner["positions"]
actual_count = sum(
len(positions_list)
for categories in positions.values()
for positions_list in categories.values()
)
assert inner["total_count"] == actual_count
# =============================================================================
# 数据文件验证:data/byod_eligible_positions.json
# =============================================================================
class TestDataFileValidation:
"""验证 BYOD 资格岗位清单 JSON 数据文件。"""
# 数据文件路径(相对于 backend/ 目录)
DATA_FILE = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"data",
"byod_eligible_positions.json",
)
@pytest.fixture
def data_content(self):
"""读取并解析数据文件。"""
with open(self.DATA_FILE, encoding="utf-8") as f:
return json.load(f)
def test_file_exists(self):
"""数据文件存在。"""
assert os.path.exists(self.DATA_FILE), f"数据文件不存在: {self.DATA_FILE}"
def test_contains_13_positions(self, data_content):
"""数据文件包含 13 个岗位。"""
positions = data_content["positions"]
total = sum(
len(positions_list)
for categories in positions.values()
for positions_list in categories.values()
)
assert total == 13
assert data_content["total_count"] == 13
def test_structure_correct(self, data_content):
"""结构正确:序列 → 类别 → 岗位列表。"""
positions = data_content["positions"]
assert isinstance(positions, dict)
for seq_name, categories in positions.items():
assert isinstance(seq_name, str)
assert isinstance(categories, dict)
for cat_name, pos_list in categories.items():
assert isinstance(cat_name, str)
assert isinstance(pos_list, list)
for pos in pos_list:
assert isinstance(pos, str)
assert len(pos) > 0
def test_application_url_correct(self, data_content):
"""application_url 正确且指向 eHR 系统。"""
url = data_content["application_url"]
assert isinstance(url, str)
assert url.startswith("https://")
assert "ehr.servyou.com.cn" in url
assert "flowid=7747" in url
def test_notes_not_empty(self, data_content):
"""notes 不为空且为字符串列表。"""
notes = data_content["notes"]
assert isinstance(notes, list)
assert len(notes) > 0
for note in notes:
assert isinstance(note, str)
assert len(note) > 0
def test_data_matches_code(self, data_content):
"""数据文件中的岗位与代码中 BYOD_ELIGIBLE_POSITIONS 一致。"""
data_positions = data_content["positions"]
code_positions = BYOD_ELIGIBLE_POSITIONS
# 逐序列、逐类别、逐岗位对比
for seq, categories in code_positions.items():
assert seq in data_positions, f"序列 '{seq}' 在数据文件中不存在"
for cat, pos_list in categories.items():
assert cat in data_positions[seq], f"类别 '{seq}/{cat}' 在数据文件中不存在"
for pos in pos_list:
assert pos in data_positions[seq][cat], (
f"岗位 '{pos}' 在数据文件中不存在"
)
def test_all_positions_have_keywords(self):
"""所有资格岗位都在 BYOD_POSITION_MATCH_KEYWORDS 中有对应关键词。"""
for seq, categories in BYOD_ELIGIBLE_POSITIONS.items():
for cat, pos_list in categories.items():
for pos in pos_list:
assert pos in BYOD_POSITION_MATCH_KEYWORDS, (
f"岗位 '{pos}' 在 BYOD_POSITION_MATCH_KEYWORDS 中没有对应关键词"
)
assert len(BYOD_POSITION_MATCH_KEYWORDS[pos]) > 0, (
f"岗位 '{pos}' 的关键词列表为空"
)
+873
View File
@@ -0,0 +1,873 @@
# =============================================================================
# IT智能服务台 — 会议室预定功能测试
# =============================================================================
# 测试覆盖:
# MeetingroomService (mock WecomService + MockRedis):
# 1. get_room_list — 缓存命中/未命中
# 2. get_room_status — 预定状态查询
# 3. get_current_status — 实时状态(free/busy/starting_soon
# 4. book_room — 预定成功后缓存清除
# 5. book_room — 时间冲突异常
# 6. cancel_booking — 取消成功后缓存清除
# 7. invalidate_room_cache — 缓存清除
# 8. get_booking_detail — 预定详情缓存
#
# API端点 (mock _get_meetingroom_service):
# 9. GET /itportal/meetingroom/list
# 10. GET /itportal/meetingroom/{id}/booking
# 11. GET /itportal/meetingroom/{id}/status
# 12. POST /itportal/meetingroom/book
# 13. DELETE /itportal/meetingroom/booking/{id}
# 14. GET /itportal/meetingroom/terminal/{sn}/binding
#
# 终端绑定CRUD (db_session + mock auth):
# 15. POST /itportal/admin/terminal-bindings — 新增
# 16. GET /itportal/admin/terminal-bindings — 列表
# 17. PUT /itportal/admin/terminal-bindings/{id} — 更新
# 18. DELETE /itportal/admin/terminal-bindings/{id} — 删除
#
# 模型:
# 19. TerminalRoomBinding — 字段定义
# 20. MeetingroomBookingSnapshot — 字段定义
# =============================================================================
import json
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from app.services.meetingroom_service import MeetingroomService
from app.models.terminal_room_binding import TerminalRoomBinding
from app.models.meetingroom_booking_snapshot import MeetingroomBookingSnapshot
from app.schemas.meetingroom import (
BookRequest,
TerminalBindingCreate,
TerminalBindingUpdate,
)
from app.dependencies import UserInfo
# =============================================================================
# 测试数据工厂
# =============================================================================
def make_room(id: int = 1, name: str = "测试会议室") -> dict:
"""构造企微API返回的会议室数据"""
return {
"meetingroom_id": id,
"name": name,
"capacity": 10,
"location": "18F东区",
"equipment": [1, 2],
"need_approval": 0,
}
def make_booking(
booking_id: str = "bk001",
subject: str = "项目周会",
start_offset_min: int = -30,
duration_min: int = 60,
status: int = 0,
) -> dict:
"""构造企微API返回的预定记录(start_offset_min 相对当前时间的偏移分钟数)"""
now = datetime.now()
start = now + timedelta(minutes=start_offset_min)
end = start + timedelta(minutes=duration_min)
return {
"booking_id": booking_id,
"subject": subject,
"booker": "zhangsan",
"booker_name": "张三",
"start_time": int(start.timestamp()),
"end_time": int(end.timestamp()),
"status": status,
}
# =============================================================================
# MockRedis — 复用 conftest 中的 MockRedis 实现
# =============================================================================
class MockRedisHelper:
"""轻量级 MockRedis,用于 service 层测试(独立于 conftest"""
def __init__(self):
self._data: dict = {}
async def get(self, key: str):
v = self._data.get(key)
if v is not None:
return v.encode("utf-8") if isinstance(v, str) else v
return None
async def setex(self, name: str, time: int, value: str):
self._data[name] = value
async def set(self, name: str, value: str, **kwargs):
self._data[name] = value
async def delete(self, *names):
count = 0
for n in names:
if n in self._data:
del self._data[n]
count += 1
return count
async def exists(self, *keys):
return sum(1 for k in keys if k in self._data)
async def expire(self, name: str, time: int):
return name in self._data
async def close(self):
pass
# =============================================================================
# MeetingroomService 单元测试
# =============================================================================
class TestMeetingroomService:
"""MeetingroomService 业务逻辑测试"""
@pytest.fixture
def mock_wecom(self):
"""创建 mock WecomService"""
wecom = AsyncMock()
wecom.get_meetingroom_list.return_value = [make_room(1, "A会议室"), make_room(2, "B会议室")]
wecom.get_booking_info.return_value = []
wecom.book_meetingroom.return_value = {"booking_id": "bk_new_001"}
wecom.cancel_booking.return_value = {"errcode": 0}
wecom.get_booking_detail.return_value = {
"booking_id": "bk001",
"subject": "详情测试",
"booker": "zhangsan",
}
return wecom
@pytest.fixture
def mock_redis(self):
return MockRedisHelper()
@pytest.fixture
def service(self, mock_wecom, mock_redis):
return MeetingroomService(wecom_service=mock_wecom, redis_client=mock_redis)
# --------------------------------------------------------------------------
# get_room_list
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_room_list_with_cache(self, service, mock_wecom, mock_redis):
"""缓存命中时不调用企微API"""
# 第一次调用 — 应调用API并缓存
result1 = await service.get_room_list()
assert len(result1) == 2
assert mock_wecom.get_meetingroom_list.call_count == 1
# 第二次调用 — 应命中缓存,不调用API
result2 = await service.get_room_list()
assert len(result2) == 2
assert mock_wecom.get_meetingroom_list.call_count == 1 # 仍是1次
@pytest.mark.asyncio
async def test_get_room_list_no_redis(self, mock_wecom):
"""无Redis时直接调用API"""
service = MeetingroomService(wecom_service=mock_wecom, redis_client=None)
result = await service.get_room_list()
assert len(result) == 2
assert mock_wecom.get_meetingroom_list.call_count == 1
# --------------------------------------------------------------------------
# get_room_status
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_room_status(self, service, mock_wecom):
"""验证预定状态查询"""
mock_wecom.get_booking_info.return_value = [
make_booking("bk001", "会议A", start_offset_min=60),
make_booking("bk002", "会议B", start_offset_min=120),
]
result = await service.get_room_status(meetingroom_id=1, date="2026-07-15")
assert len(result) == 2
assert result[0]["booking_id"] == "bk001"
assert mock_wecom.get_booking_info.call_count == 1
# 验证传给企微API的时间范围参数
call_args = mock_wecom.get_booking_info.call_args
assert call_args[0][0] == 1 # meetingroom_id
assert "2026-07-15T00:00:00" in call_args[0][1] # start_time
assert "2026-07-15T23:59:59" in call_args[0][2] # end_time
@pytest.mark.asyncio
async def test_get_room_status_default_date(self, service, mock_wecom):
"""未传date时默认今天"""
await service.get_room_status(meetingroom_id=1)
today = datetime.now().strftime("%Y-%m-%d")
call_args = mock_wecom.get_booking_info.call_args
assert today in call_args[0][1]
# --------------------------------------------------------------------------
# get_current_status
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_current_status_free(self, service, mock_wecom):
"""当前空闲状态判断"""
# 预定在未来2小时,不在15分钟内 → free
mock_wecom.get_booking_info.return_value = [
make_booking("bk001", "未来会议", start_offset_min=120, duration_min=60),
]
result = await service.get_current_status(meetingroom_id=1)
assert result["status"] == "free"
assert result["current_meeting"] is None
assert result["next_meeting"] is not None
assert result["minutes_to_next"] is not None
assert result["minutes_to_next"] > 15
@pytest.mark.asyncio
async def test_get_current_status_busy(self, service, mock_wecom):
"""当前使用中状态判断"""
# 预定在当前时间段内 → busy
mock_wecom.get_booking_info.return_value = [
make_booking("bk001", "进行中会议", start_offset_min=-10, duration_min=30),
]
result = await service.get_current_status(meetingroom_id=1)
assert result["status"] == "busy"
assert result["current_meeting"] is not None
assert result["current_meeting"]["subject"] == "进行中会议"
@pytest.mark.asyncio
async def test_get_current_status_starting_soon(self, service, mock_wecom):
"""即将开始状态判断(15分钟内)"""
# 预定在5分钟后开始 → starting_soon
mock_wecom.get_booking_info.return_value = [
make_booking("bk001", "即将开始", start_offset_min=5, duration_min=30),
]
result = await service.get_current_status(meetingroom_id=1)
assert result["status"] == "starting_soon"
assert result["next_meeting"] is not None
assert result["minutes_to_next"] <= 15
@pytest.mark.asyncio
async def test_get_current_status_no_bookings(self, service, mock_wecom):
"""无预定时返回free"""
mock_wecom.get_booking_info.return_value = []
result = await service.get_current_status(meetingroom_id=1)
assert result["status"] == "free"
assert result["current_meeting"] is None
assert result["next_meeting"] is None
assert result["minutes_to_next"] is None
@pytest.mark.asyncio
async def test_get_current_status_skip_cancelled(self, service, mock_wecom):
"""跳过已取消的预定"""
mock_wecom.get_booking_info.return_value = [
make_booking("bk001", "已取消", start_offset_min=-10, duration_min=30, status=1),
]
result = await service.get_current_status(meetingroom_id=1)
assert result["status"] == "free"
# --------------------------------------------------------------------------
# book_room
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_book_room_success(self, service, mock_wecom, mock_redis):
"""预定成功后缓存被清除"""
# 先写入缓存
cache_key = service.CACHE_KEY_BOOKING_INFO.format(room_id=1, date=datetime.now().strftime("%Y-%m-%d"))
await mock_redis.setex(cache_key, 30, json.dumps([{"old": True}]))
# 执行预定
result = await service.book_room(
meetingroom_id=1,
subject="测试预定",
start_time="2026-07-15T10:00:00+08:00",
end_time="2026-07-15T11:00:00+08:00",
booker="zhangsan",
)
assert result["booking_id"] == "bk_new_001"
assert mock_wecom.book_meetingroom.call_count == 1
# 验证缓存被清除
cached = await mock_redis.get(cache_key)
assert cached is None
@pytest.mark.asyncio
async def test_book_room_conflict(self, service, mock_wecom):
"""时间冲突处理 — 企微API抛异常时透传"""
mock_wecom.book_meetingroom.side_effect = Exception("时间冲突: 该时段已被预定")
with pytest.raises(Exception, match="时间冲突"):
await service.book_room(
meetingroom_id=1,
subject="冲突预定",
start_time="2026-07-15T10:00:00+08:00",
end_time="2026-07-15T11:00:00+08:00",
booker="zhangsan",
)
# --------------------------------------------------------------------------
# cancel_booking
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cancel_booking_success(self, service, mock_wecom, mock_redis):
"""取消成功后缓存被清除"""
# 先写入状态缓存
status_key = service.CACHE_KEY_STATUS.format(room_id=1)
await mock_redis.setex(status_key, 10, json.dumps({"status": "busy"}))
# 执行取消
result = await service.cancel_booking(booking_id="bk001", meetingroom_id=1)
assert result["errcode"] == 0
assert mock_wecom.cancel_booking.call_count == 1
# 验证缓存被清除
cached = await mock_redis.get(status_key)
assert cached is None
# --------------------------------------------------------------------------
# invalidate_room_cache
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_invalidate_room_cache(self, service, mock_redis):
"""缓存清除"""
today = datetime.now().strftime("%Y-%m-%d")
booking_key = service.CACHE_KEY_BOOKING_INFO.format(room_id=1, date=today)
status_key = service.CACHE_KEY_STATUS.format(room_id=1)
# 写入缓存
await mock_redis.setex(booking_key, 30, json.dumps([{"test": True}]))
await mock_redis.setex(status_key, 10, json.dumps({"status": "busy"}))
# 清除缓存
await service.invalidate_room_cache(meetingroom_id=1)
# 验证两个缓存key都被删除
assert await mock_redis.get(booking_key) is None
assert await mock_redis.get(status_key) is None
@pytest.mark.asyncio
async def test_invalidate_room_cache_no_redis(self, mock_wecom):
"""无Redis时invalidate不报错"""
service = MeetingroomService(wecom_service=mock_wecom, redis_client=None)
await service.invalidate_room_cache(meetingroom_id=1) # 不应抛异常
# --------------------------------------------------------------------------
# get_booking_detail
# --------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_booking_detail_with_cache(self, service, mock_wecom, mock_redis):
"""预定详情缓存"""
result1 = await service.get_booking_detail(meetingroom_id=1, booking_id="bk001")
assert result1["booking_id"] == "bk001"
assert mock_wecom.get_booking_detail.call_count == 1
# 第二次应命中缓存
result2 = await service.get_booking_detail(meetingroom_id=1, booking_id="bk001")
assert result2["booking_id"] == "bk001"
assert mock_wecom.get_booking_detail.call_count == 1 # 仍1次
# --------------------------------------------------------------------------
# _parse_booking_time
# --------------------------------------------------------------------------
def test_parse_booking_time_timestamp(self):
"""解析时间戳格式"""
ts = int(datetime(2026, 7, 15, 10, 0, 0).timestamp())
result = MeetingroomService._parse_booking_time(ts)
assert result is not None
assert result.year == 2026
assert result.month == 7
assert result.day == 15
def test_parse_booking_time_iso(self):
"""解析ISO字符串格式"""
result = MeetingroomService._parse_booking_time("2026-07-15T10:00:00")
assert result is not None
assert result.year == 2026
def test_parse_booking_time_none(self):
"""None输入返回None"""
assert MeetingroomService._parse_booking_time(None) is None
def test_parse_booking_time_invalid(self):
"""无效输入返回None"""
assert MeetingroomService._parse_booking_time("invalid") is None
# =============================================================================
# API 端点测试 — 会议室预定
# =============================================================================
class TestMeetingroomAPI:
"""会议室预定API端点测试"""
@pytest.fixture
def mock_service(self):
"""创建 mock MeetingroomService"""
svc = AsyncMock(spec=MeetingroomService)
svc.get_room_list.return_value = [make_room(1, "A会议室"), make_room(2, "B会议室")]
svc.get_room_status.return_value = [
make_booking("bk001", "会议A", start_offset_min=60),
]
svc.get_current_status.return_value = {
"status": "free",
"current_meeting": None,
"next_meeting": None,
"minutes_to_next": None,
"bookings": [],
}
svc.book_room.return_value = {"booking_id": "bk_new_001"}
svc.cancel_booking.return_value = {"errcode": 0}
svc.get_booking_detail.return_value = {
"booking_id": "bk001",
"subject": "详情",
"booker": "zhangsan",
"booker_name": "张三",
"attendees": ["lisi"],
"start_time": "2026-07-15T10:00:00",
"end_time": "2026-07-15T11:00:00",
}
svc._format_booking = MeetingroomService._format_booking
return svc
@pytest_asyncio.fixture
async def meetingroom_client(self, mock_service, db_session):
"""创建测试客户端,mock掉 _get_meetingroom_service。
使用独立 FastAPI app(只挂载 meetingroom 路由),
避免全量 create_app() 触发 itsm_service.py 中 httpx.Timeout 兼容性问题。
"""
from httpx import ASGITransport, AsyncClient
from fastapi import FastAPI
from app.database import get_db
from app.utils.response import AppException, app_exception_handler
app = FastAPI()
# 注册全局异常处理器(让 AppException 返回 JSON 而非 500
app.add_exception_handler(AppException, app_exception_handler)
# 只挂载会议室预定路由
from app.api.meetingroom import router as meetingroom_router
app.include_router(meetingroom_router)
async def _override_db():
yield db_session
app.dependency_overrides[get_db] = _override_db
with patch("app.api.meetingroom._get_meetingroom_service", return_value=mock_service):
with patch("app.api.meetingroom.WecomService", return_value=AsyncMock()):
with patch("app.services.wecom_service.WecomService", return_value=AsyncMock()):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_get_meetingroom_list(self, meetingroom_client, mock_service):
"""GET /itportal/meetingroom/list"""
resp = await meetingroom_client.get("/itportal/meetingroom/list")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert len(data["data"]["rooms"]) == 2
assert data["data"]["rooms"][0]["name"] == "A会议室"
mock_service.get_room_list.assert_called_once()
@pytest.mark.asyncio
async def test_get_booking_info(self, meetingroom_client, mock_service):
"""GET /itportal/meetingroom/{id}/booking"""
resp = await meetingroom_client.get("/itportal/meetingroom/1/booking")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert len(data["data"]["bookings"]) == 1
@pytest.mark.asyncio
async def test_get_current_status(self, meetingroom_client, mock_service):
"""GET /itportal/meetingroom/{id}/status"""
resp = await meetingroom_client.get("/itportal/meetingroom/1/status")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["status"] == "free"
@pytest.mark.asyncio
async def test_get_terminal_binding_not_found(self, meetingroom_client):
"""GET /itportal/meetingroom/terminal/{sn}/binding — 未绑定时返回null"""
resp = await meetingroom_client.get("/itportal/meetingroom/terminal/UNKNOWN_SN/binding")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"] is None
# =============================================================================
# API 端点测试 — 终端绑定CRUD
# =============================================================================
class TestTerminalBindingCRUD:
"""终端绑定管理CRUD测试"""
@pytest_asyncio.fixture
async def admin_client(self, db_session):
"""创建带admin权限的测试客户端。
使用独立 FastAPI app(只挂载 terminal_binding 路由),
避免全量 create_app() 触发 itsm_service.py 中 httpx.Timeout 兼容性问题。
"""
from httpx import ASGITransport, AsyncClient
from fastapi import FastAPI
from app.database import get_db
from app.dependencies import get_current_user
from app.utils.response import AppException, app_exception_handler
app = FastAPI()
# 注册全局异常处理器(让 AppException 返回 JSON 而非 500
app.add_exception_handler(AppException, app_exception_handler)
from app.api.admin.terminal_binding import router as terminal_binding_router
app.include_router(terminal_binding_router)
async def _override_db():
yield db_session
admin_user = UserInfo(
employee_id="admin001",
name="管理员",
department="IT部",
avatar="",
roles=["admin"],
current_role="admin",
login_source="test",
)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_current_user] = lambda: admin_user
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_create_binding(self, admin_client, db_session):
"""POST /itportal/admin/terminal-bindings — 新增绑定"""
resp = await admin_client.post("/itportal/admin/terminal-bindings", json={
"terminal_sn": "SN_TEST_001",
"terminal_name": "18F东区大屏",
"meetingroom_id": 1,
"meetingroom_name": "18F东区会议室",
"location": "18F东区",
})
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert "id" in data["data"]
binding_id = data["data"]["id"]
# 验证数据已写入
from sqlalchemy import select
result = await db_session.execute(
select(TerminalRoomBinding).where(TerminalRoomBinding.id == binding_id)
)
binding = result.scalar_one_or_none()
assert binding is not None
assert binding.terminal_sn == "SN_TEST_001"
assert binding.meetingroom_id == 1
assert binding.is_active is True
@pytest.mark.asyncio
async def test_create_binding_duplicate(self, admin_client, db_session):
"""重复绑定同一terminal_sn应报错"""
# 先创建一条
db_session.add(TerminalRoomBinding(
terminal_sn="SN_DUP_001",
terminal_name="终端1",
meetingroom_id=1,
meetingroom_name="会议室A",
location="18F",
is_active=True,
))
await db_session.flush()
# 再创建同SN的
resp = await admin_client.post("/itportal/admin/terminal-bindings", json={
"terminal_sn": "SN_DUP_001",
"terminal_name": "终端2",
"meetingroom_id": 2,
"meetingroom_name": "会议室B",
"location": "19F",
})
assert resp.status_code == 200
data = resp.json()
assert data["code"] != 0 # 应返回错误
@pytest.mark.asyncio
async def test_list_bindings(self, admin_client, db_session):
"""GET /itportal/admin/terminal-bindings — 列表查询"""
# 插入测试数据
for i in range(3):
db_session.add(TerminalRoomBinding(
terminal_sn=f"SN_LIST_{i:03d}",
terminal_name=f"终端{i}",
meetingroom_id=i + 1,
meetingroom_name=f"会议室{i+1}",
location=f"{i+10}F",
is_active=True,
))
await db_session.flush()
resp = await admin_client.get("/itportal/admin/terminal-bindings")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["total"] >= 3
assert len(data["data"]["list"]) >= 3
@pytest.mark.asyncio
async def test_list_bindings_with_keyword(self, admin_client, db_session):
"""GET /itportal/admin/terminal-bindings?keyword=xxx — 关键词搜索"""
db_session.add(TerminalRoomBinding(
terminal_sn="SN_KW_001",
terminal_name="大屏A",
meetingroom_id=1,
meetingroom_name="会议室A",
location="18F",
is_active=True,
))
db_session.add(TerminalRoomBinding(
terminal_sn="SN_KW_002",
terminal_name="大屏B",
meetingroom_id=2,
meetingroom_name="会议室B",
location="19F",
is_active=True,
))
await db_session.flush()
resp = await admin_client.get("/itportal/admin/terminal-bindings?keyword=大屏A")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
# 至少包含"大屏A"的记录
names = [item["terminal_name"] for item in data["data"]["list"]]
assert "大屏A" in names
@pytest.mark.asyncio
async def test_update_binding(self, admin_client, db_session):
"""PUT /itportal/admin/terminal-bindings/{id} — 更新绑定"""
binding = TerminalRoomBinding(
terminal_sn="SN_UPD_001",
terminal_name="旧名称",
meetingroom_id=1,
meetingroom_name="旧会议室",
location="18F",
is_active=True,
)
db_session.add(binding)
await db_session.flush()
resp = await admin_client.put(f"/itportal/admin/terminal-bindings/{binding.id}", json={
"terminal_name": "新名称",
"meetingroom_name": "新会议室",
"is_active": False,
})
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
# 验证更新生效(flush 确保修改写入 session,再检查属性)
await db_session.flush()
assert binding.terminal_name == "新名称"
assert binding.meetingroom_name == "新会议室"
assert binding.is_active is False
@pytest.mark.asyncio
async def test_update_binding_not_found(self, admin_client):
"""更新不存在的绑定应报错"""
resp = await admin_client.put("/itportal/admin/terminal-bindings/99999", json={
"terminal_name": "测试",
})
assert resp.status_code == 200
data = resp.json()
assert data["code"] != 0
@pytest.mark.asyncio
async def test_delete_binding(self, admin_client, db_session):
"""DELETE /itportal/admin/terminal-bindings/{id} — 删除绑定"""
binding = TerminalRoomBinding(
terminal_sn="SN_DEL_001",
terminal_name="待删除",
meetingroom_id=1,
meetingroom_name="会议室",
location="18F",
is_active=True,
)
db_session.add(binding)
await db_session.flush()
binding_id = binding.id
resp = await admin_client.delete(f"/itportal/admin/terminal-bindings/{binding_id}")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
@pytest.mark.asyncio
async def test_delete_binding_not_found(self, admin_client):
"""删除不存在的绑定应报错"""
resp = await admin_client.delete("/itportal/admin/terminal-bindings/99999")
assert resp.status_code == 200
data = resp.json()
assert data["code"] != 0
# =============================================================================
# 模型测试
# =============================================================================
class TestModels:
"""模型字段定义测试"""
def test_terminal_room_binding_model(self):
"""TerminalRoomBinding 模型字段定义正确"""
# 验证表名
assert TerminalRoomBinding.__tablename__ == "terminal_room_binding"
# 验证字段存在
columns = {c.name for c in TerminalRoomBinding.__table__.columns}
expected = {
"id", "terminal_sn", "terminal_name", "meetingroom_id",
"meetingroom_name", "location", "is_active",
"created_at", "updated_at",
}
assert expected.issubset(columns), f"缺失字段: {expected - columns}"
# 验证 terminal_sn 唯一索引
sn_col = TerminalRoomBinding.__table__.columns["terminal_sn"]
assert sn_col.unique is True
# 验证 meetingroom_id 有索引(非唯一)
room_col = TerminalRoomBinding.__table__.columns["meetingroom_id"]
assert room_col.index is True
# 验证默认值
assert TerminalRoomBinding.__table__.columns["is_active"].default.arg is True
def test_meetingroom_booking_snapshot_model(self):
"""MeetingroomBookingSnapshot 模型字段定义正确"""
# 验证表名
assert MeetingroomBookingSnapshot.__tablename__ == "meetingroom_booking_snapshot"
# 验证字段存在
columns = {c.name for c in MeetingroomBookingSnapshot.__table__.columns}
expected = {
"id", "meetingroom_id", "booking_id", "subject", "booker",
"start_time", "end_time", "status", "snapshot_date", "created_at",
}
assert expected.issubset(columns), f"缺失字段: {expected - columns}"
# 验证索引字段
assert MeetingroomBookingSnapshot.__table__.columns["meetingroom_id"].index is True
assert MeetingroomBookingSnapshot.__table__.columns["booking_id"].index is True
assert MeetingroomBookingSnapshot.__table__.columns["snapshot_date"].index is True
# 验证默认值
assert MeetingroomBookingSnapshot.__table__.columns["status"].default.arg == 0
@pytest.mark.asyncio
async def test_terminal_room_binding_repr(self):
"""TerminalRoomBinding __repr__ 方法"""
binding = TerminalRoomBinding(
terminal_sn="SN_REPR_001",
terminal_name="测试终端",
meetingroom_id=1,
meetingroom_name="测试会议室",
)
repr_str = repr(binding)
assert "SN_REPR_001" in repr_str
assert "测试会议室" in repr_str
@pytest.mark.asyncio
async def test_meetingroom_booking_snapshot_repr(self):
"""MeetingroomBookingSnapshot __repr__ 方法"""
from datetime import date
snapshot = MeetingroomBookingSnapshot(
meetingroom_id=1,
booking_id="bk_repr_001",
subject="测试快照",
start_time=datetime(2026, 7, 15, 10, 0, 0),
end_time=datetime(2026, 7, 15, 11, 0, 0),
snapshot_date=date(2026, 7, 15),
)
repr_str = repr(snapshot)
assert "bk_repr_001" in repr_str
assert "测试快照" in repr_str
# =============================================================================
# Pydantic Schema 测试
# =============================================================================
class TestSchemas:
"""Pydantic 请求/响应模型测试"""
def test_book_request_valid(self):
"""BookRequest 验证合法请求"""
req = BookRequest(
meetingroom_id=1,
subject="测试预定",
start_time="2026-07-15T10:00:00+08:00",
end_time="2026-07-15T11:00:00+08:00",
booker="zhangsan",
)
assert req.meetingroom_id == 1
assert req.subject == "测试预定"
def test_book_request_empty_subject(self):
"""BookRequest 空主题应验证失败"""
with pytest.raises(Exception):
BookRequest(
meetingroom_id=1,
subject="",
start_time="2026-07-15T10:00:00+08:00",
end_time="2026-07-15T11:00:00+08:00",
booker="zhangsan",
)
def test_terminal_binding_create_valid(self):
"""TerminalBindingCreate 验证"""
req = TerminalBindingCreate(
terminal_sn="SN001",
terminal_name="终端1",
meetingroom_id=1,
meetingroom_name="会议室1",
location="18F",
)
assert req.terminal_sn == "SN001"
def test_terminal_binding_create_empty_sn(self):
"""空terminal_sn应验证失败"""
with pytest.raises(Exception):
TerminalBindingCreate(
terminal_sn="",
meetingroom_id=1,
)
def test_terminal_binding_update_partial(self):
"""TerminalBindingUpdate 部分更新"""
req = TerminalBindingUpdate(terminal_name="新名称")
assert req.terminal_name == "新名称"
assert req.meetingroom_id is None
assert req.is_active is None
+731
View File
@@ -0,0 +1,731 @@
# =============================================================================
# 企微IT智能服务台 — 复杂场景重构第二阶段 P2/P3 单元测试
# =============================================================================
# 测试范围:TokenCounter、ContextCompressor、SnapshotService、CorrectionService
# 创建日期: 2026-07-11
# =============================================================================
import os
import sys
# 设置测试环境变量(在导入 app 之前)
os.environ.setdefault("DEV_MODE", "true")
os.environ.setdefault("WECOM_SSO_CALLBACK_BASE", "https://test.example.com")
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime, timezone
# =============================================================================
# TokenCounter 测试
# =============================================================================
class TestTokenCounter:
"""TokenCounter 工具类测试。"""
def test_count_tokens_empty(self):
"""空文本/None 返回 0。"""
from app.utils.token_counter import TokenCounter
assert TokenCounter.count_tokens("") == 0
assert TokenCounter.count_tokens(None) == 0
def test_count_tokens_non_empty(self):
"""非空文本返回正数。"""
from app.utils.token_counter import TokenCounter
assert TokenCounter.count_tokens("Hello world") > 0
assert TokenCounter.count_tokens("你好世界") > 0
def test_count_tokens_long_text(self):
"""长文本 token 数应大于短文本。"""
from app.utils.token_counter import TokenCounter
short = TokenCounter.count_tokens("hi")
long = TokenCounter.count_tokens("This is a much longer piece of text " * 100)
assert long > short
def test_count_messages_tokens(self):
"""消息列表 token 计数包含每条4 token overhead。"""
from app.utils.token_counter import TokenCounter
messages = [
{"role": "user", "content": "你好"},
{"role": "assistant", "content": "您好,有什么可以帮您?"},
]
total = TokenCounter.count_messages_tokens(messages)
assert total > 0
# 应该比单独文本token之和大(因为每条有4 token overhead
text_only = sum(
TokenCounter.count_tokens(m["content"]) + TokenCounter.count_tokens(m["role"])
for m in messages
)
assert total == text_only + 8 # 2条消息 x 4 overhead
def test_count_messages_tokens_empty(self):
"""空消息列表返回 0。"""
from app.utils.token_counter import TokenCounter
assert TokenCounter.count_messages_tokens([]) == 0
def test_is_precise_returns_bool(self):
"""is_precise 返回布尔值。"""
from app.utils.token_counter import TokenCounter
assert isinstance(TokenCounter.is_precise(), bool)
def test_count_tokens_consistency(self):
"""相同输入应返回相同结果(幂等性)。"""
from app.utils.token_counter import TokenCounter
text = "这是一段测试文本 for consistency check"
result1 = TokenCounter.count_tokens(text)
result2 = TokenCounter.count_tokens(text)
assert result1 == result2
# =============================================================================
# SnapshotService 测试
# =============================================================================
class TestSnapshotService:
"""SnapshotService 快照服务测试。"""
def _make_mock_item(self, name="工号", value="12345", version=1, item_id="item-1"):
"""创建 mock 信息项。"""
mock_item = MagicMock()
mock_item.name = name
mock_item.value = value
mock_item.version = version
mock_item.id = item_id
return mock_item
@pytest.mark.asyncio
async def test_create_snapshot(self):
"""测试创建快照。"""
from app.services.automation.snapshot_service import SnapshotService
db = AsyncMock()
mock_item = self._make_mock_item()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_item]
db.execute.return_value = mock_result
svc = SnapshotService(db)
snapshot = await svc.create_snapshot("session-1", "工号", [])
assert snapshot is not None
assert snapshot.trigger_item_key == "工号"
assert snapshot.session_id == "session-1"
assert snapshot.is_undone is False
db.add.assert_called_once()
db.flush.assert_called_once()
@pytest.mark.asyncio
async def test_create_snapshot_multiple_items(self):
"""测试多信息项快照。"""
from app.services.automation.snapshot_service import SnapshotService
db = AsyncMock()
item1 = self._make_mock_item("工号", "10001", 1, "id-1")
item2 = self._make_mock_item("姓名", "张三", 2, "id-2")
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [item1, item2]
db.execute.return_value = mock_result
svc = SnapshotService(db)
snapshot = await svc.create_snapshot("session-1", "工号", [])
assert snapshot is not None
assert snapshot.trigger_item_key == "工号"
@pytest.mark.asyncio
async def test_undo_limit_exceeded(self):
"""测试撤销次数超限。"""
from app.services.automation.snapshot_service import (
SnapshotService,
MAX_UNDO_COUNT,
)
db = AsyncMock()
# Mock: 已撤销次数 >= MAX_UNDO_COUNT
mock_count_result = MagicMock()
mock_count_result.scalar.return_value = MAX_UNDO_COUNT
db.execute.return_value = mock_count_result
svc = SnapshotService(db)
with pytest.raises(ValueError, match="超限"):
await svc.undo_correction("session-1")
@pytest.mark.asyncio
async def test_undo_no_snapshot(self):
"""测试无可撤销快照。"""
from app.services.automation.snapshot_service import SnapshotService
db = AsyncMock()
# Mock: 已撤销次数 = 0 (未超限)
mock_count_result = MagicMock()
mock_count_result.scalar.return_value = 0
# Mock: 无未撤销快照
mock_snapshot_result = MagicMock()
mock_snapshot_result.scalar_one_or_none.return_value = None
# db.execute 需要返回不同结果(先 count,后 snapshot
db.execute.side_effect = [mock_count_result, mock_snapshot_result]
svc = SnapshotService(db)
with pytest.raises(ValueError, match="无可撤销"):
await svc.undo_correction("session-1")
@pytest.mark.asyncio
async def test_get_snapshot_history_empty(self):
"""测试空快照历史。"""
from app.services.automation.snapshot_service import SnapshotService
db = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
db.execute.return_value = mock_result
svc = SnapshotService(db)
history = await svc.get_snapshot_history("session-1")
assert history == []
@pytest.mark.asyncio
async def test_get_version_diff_item_not_found(self):
"""测试版本对比时信息项不存在。"""
from app.services.automation.snapshot_service import SnapshotService
db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
db.execute.return_value = mock_result
svc = SnapshotService(db)
with pytest.raises(ValueError, match="不存在"):
await svc.get_version_diff("session-1", "不存在项", 1, 2)
@pytest.mark.asyncio
async def test_get_latest_snapshot_none(self):
"""测试无最新快照。"""
from app.services.automation.snapshot_service import SnapshotService
db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
db.execute.return_value = mock_result
svc = SnapshotService(db)
snapshot = await svc.get_latest_snapshot("session-1")
assert snapshot is None
# =============================================================================
# CorrectionService 测试
# =============================================================================
class TestCorrectionService:
"""CorrectionService 纠错服务测试。"""
@pytest.mark.asyncio
async def test_batch_correct_empty_corrections(self):
"""测试空更正列表抛异常。"""
from app.services.automation.correction_service import CorrectionService
db = AsyncMock()
svc = CorrectionService(db)
with pytest.raises(Exception):
await svc.batch_correct("session-1", [])
@pytest.mark.asyncio
async def test_check_dependencies(self):
"""测试依赖检查。"""
from app.services.automation.correction_service import CorrectionService
db = AsyncMock()
# Mock: 有一个依赖信息项
mock_item = MagicMock()
mock_item.name = "设备分配人"
mock_item.value = "张三"
mock_item.derived_from = ["工号"]
mock_item.session_id = "session-1"
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_item]
db.execute.return_value = mock_result
svc = CorrectionService(db)
warnings = await svc.check_dependencies("session-1", "工号")
assert len(warnings) == 1
assert warnings[0]["item_key"] == "设备分配人"
assert warnings[0]["derived_from"] == "工号"
@pytest.mark.asyncio
async def test_check_dependencies_no_deps(self):
"""测试无依赖项时返回空列表。"""
from app.services.automation.correction_service import CorrectionService
db = AsyncMock()
mock_item = MagicMock()
mock_item.name = "工号"
mock_item.value = "12345"
mock_item.derived_from = None
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_item]
db.execute.return_value = mock_result
svc = CorrectionService(db)
warnings = await svc.check_dependencies("session-1", "工号")
assert warnings == []
@pytest.mark.asyncio
async def test_get_version_chain_empty(self):
"""测试空版本链(信息项不存在)。"""
from app.services.automation.correction_service import CorrectionService
db = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
db.execute.return_value = mock_result
svc = CorrectionService(db)
chain = await svc.get_version_chain("session-1", "不存在项")
assert chain == []
@pytest.mark.asyncio
async def test_get_version_chain_with_history(self):
"""测试带历史记录的版本链。"""
from app.services.automation.correction_service import CorrectionService
db = AsyncMock()
mock_item = MagicMock()
mock_item.name = "工号"
mock_item.value = "99999"
mock_item.version = 3
mock_item.derived_from = None
mock_item.correction_reason = "用户更正"
mock_item.updated_at = datetime(2026, 7, 11, 10, 0, 0, tzinfo=timezone.utc)
mock_item.update_history = [
{
"version": 1,
"old_value": "11111",
"new_value": "22222",
"action": "correct",
"reason": "初次更正",
"timestamp": "2026-07-11T09:00:00+00:00",
},
{
"version": 2,
"old_value": "22222",
"new_value": "99999",
"action": "correct",
"reason": "再次更正",
"timestamp": "2026-07-11T09:30:00+00:00",
},
]
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_item
db.execute.return_value = mock_result
svc = CorrectionService(db)
chain = await svc.get_version_chain("session-1", "工号")
# 2 history entries + 1 current = 3
assert len(chain) == 3
# First entry
assert chain[0]["version"] == 1
assert chain[0]["value"] == "11111"
assert chain[0]["new_value"] == "22222"
assert chain[0]["action"] == "correct"
# Last entry (current)
assert chain[-1]["version"] == 3
assert chain[-1]["value"] == "99999"
assert chain[-1]["action"] == "current"
@pytest.mark.asyncio
async def test_get_correction_history_empty(self):
"""测试空更正历史。"""
from app.services.automation.correction_service import CorrectionService
db = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
db.execute.return_value = mock_result
svc = CorrectionService(db)
history = await svc.get_correction_history("session-1")
assert history == []
# =============================================================================
# ContextCompressor 测试
# =============================================================================
class TestContextCompressor:
"""ContextCompressor 上下文压缩引擎测试。"""
def test_should_compress_under_threshold(self):
"""测试未超阈值不压缩。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
# 少量消息不应触发压缩(阈值默认6000)
messages = [{"role": "user", "content": "你好"}]
assert compressor.should_compress(messages) is False
def test_should_compress_over_threshold(self):
"""测试超阈值触发压缩。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
compressor.threshold = 10 # 设置极低阈值
messages = [{"role": "user", "content": "这是一段很长的对话内容" * 10}]
assert compressor.should_compress(messages) is True
def test_should_compress_empty_messages(self):
"""测试空消息列表不压缩。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
assert compressor.should_compress([]) is False
def test_count_tokens_method(self):
"""测试 count_tokens 方法。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
messages = [{"role": "user", "content": "Hello"}]
assert compressor.count_tokens(messages) > 0
def test_count_tokens_empty(self):
"""测试空消息 token 数为 0。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
assert compressor.count_tokens([]) == 0
def test_extract_key_info_empty(self):
"""测试空信息项提取。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
result = compressor._extract_key_info([], [], "")
assert "暂无" in result["info_items"]
assert "暂无" in result["actions"]
def test_extract_key_info_with_items(self):
"""测试有信息项时正确提取。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
mock_item = MagicMock()
mock_item.name = "工号"
mock_item.value = "12345"
mock_item.version = 2
mock_item.is_filled = True
mock_action = MagicMock()
mock_action.title = "终端扫描"
mock_action.action_type = "virus_scan"
mock_action.status = "success"
result = compressor._extract_key_info([mock_item], [mock_action], "扫描中")
assert "工号" in result["info_items"]
assert "12345" in result["info_items"]
assert "v2" in result["info_items"]
assert "终端扫描" in result["actions"]
assert "" in result["actions"]
def test_extract_key_info_unfilled_item(self):
"""测试未填写信息项被过滤。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
mock_item = MagicMock()
mock_item.name = "工号"
mock_item.value = "12345"
mock_item.version = 1
mock_item.is_filled = False # 未填写
result = compressor._extract_key_info([mock_item], [], "")
# 未填写项应被过滤,显示"暂无"
assert "暂无" in result["info_items"]
def test_get_recent_messages(self):
"""测试获取最近N轮对话。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
messages = [
{"role": "user", "content": "msg1"},
{"role": "assistant", "content": "reply1"},
{"role": "user", "content": "msg2"},
{"role": "assistant", "content": "reply2"},
{"role": "user", "content": "msg3"},
{"role": "assistant", "content": "reply3"},
]
# 取最近2轮 = 4条消息
recent = compressor._get_recent_messages(messages, 2)
assert len(recent) == 4
assert recent[-1]["content"] == "reply3"
def test_get_recent_messages_empty(self):
"""测试空消息列表。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
recent = compressor._get_recent_messages([], 2)
assert recent == []
def test_get_recent_messages_more_than_available(self):
"""测试请求轮数超过实际轮数。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
messages = [
{"role": "user", "content": "msg1"},
{"role": "assistant", "content": "reply1"},
]
# 请求5轮但只有1轮 = 2条消息
recent = compressor._get_recent_messages(messages, 5)
assert len(recent) == 2
def test_format_recent_messages_empty(self):
"""测试格式化空消息。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
result = compressor._format_recent_messages([])
assert "" in result
def test_format_recent_messages_non_empty(self):
"""测试格式化非空消息。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
messages = [
{"role": "user", "content": "你好"},
{"role": "assistant", "content": "您好"},
]
result = compressor._format_recent_messages(messages)
assert "user" in result
assert "assistant" in result
assert "你好" in result
def test_truncate_messages(self):
"""测试降级截断方法。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
messages = [
{"role": "user", "content": "msg1"},
{"role": "assistant", "content": "reply1"},
{"role": "user", "content": "msg2"},
{"role": "assistant", "content": "reply2"},
]
result = compressor._truncate_messages(messages, [], [], "")
assert len(result) >= 1
assert result[0]["role"] == "system"
assert "降级" in result[0]["content"]
@pytest.mark.asyncio
async def test_compress_with_no_llm(self):
"""测试无LLM客户端时的压缩流程。"""
from app.services.automation.context_compressor import ContextCompressor
db = AsyncMock()
compressor = ContextCompressor(db)
compressor.threshold = 5 # 极低阈值触发压缩
messages = []
for i in range(10):
messages.append({"role": "user", "content": f"消息内容{i}"})
messages.append({"role": "assistant", "content": f"回复内容{i}"})
result = await compressor.compress("session-1", messages, [], [])
assert "compressed_messages" in result
assert "tokens_before" in result
assert "tokens_after" in result
assert "compression_ratio" in result
assert "compression_level" in result
assert "summary" in result
assert "duration_ms" in result
# 压缩后消息应少于原消息
assert len(result["compressed_messages"]) <= len(messages)
# =============================================================================
# 模型层测试
# =============================================================================
class TestP2P3Models:
"""P2/P3 数据模型测试。"""
def test_context_compression_model_fields(self):
"""测试 ContextCompression 模型字段。"""
from app.models.automation import ContextCompression
# 验证表名
assert ContextCompression.__tablename__ == "auto_context_compressions"
# 验证列存在
columns = ContextCompression.__table__.columns
col_names = {c.name for c in columns}
expected = {
"id", "session_id", "tokens_before", "tokens_after",
"compression_ratio", "task_node", "duration_ms",
"compression_level", "summary", "created_at",
}
assert expected.issubset(col_names)
def test_information_snapshot_model_fields(self):
"""测试 InformationSnapshot 模型字段。"""
from app.models.automation import InformationSnapshot
assert InformationSnapshot.__tablename__ == "auto_information_snapshots"
columns = InformationSnapshot.__table__.columns
col_names = {c.name for c in columns}
expected = {
"id", "session_id", "trigger_item_key", "snapshot_data",
"correction_ids", "is_undone", "created_at",
}
assert expected.issubset(col_names)
def test_information_item_p2p3_fields(self):
"""测试 InformationItem 模型新增 P2/P3 字段。"""
from app.models.automation import InformationItem
columns = InformationItem.__table__.columns
col_names = {c.name for c in columns}
assert "derived_from" in col_names
assert "correction_reason" in col_names
# =============================================================================
# Schema 测试
# =============================================================================
class TestP2P3Schemas:
"""P2/P3 Schema 测试。"""
def test_batch_correct_request_schema(self):
"""测试批量更正请求 Schema。"""
from app.schemas.automation import BatchCorrectRequest
req = BatchCorrectRequest(
corrections=[{"field": "工号", "new_value": "99999"}],
reason="用户更正",
)
assert len(req.corrections) == 1
assert req.reason == "用户更正"
def test_batch_correct_response_schema(self):
"""测试批量更正响应 Schema。"""
from app.schemas.automation import BatchCorrectResponse
resp = BatchCorrectResponse(
corrected_items=[{"name": "工号", "value": "99999", "version": 2}],
snapshot_id=1,
dependency_warnings=[],
)
assert resp.snapshot_id == 1
assert len(resp.corrected_items) == 1
def test_undo_correction_response_schema(self):
"""测试撤销更正响应 Schema。"""
from app.schemas.automation import UndoCorrectionResponse
resp = UndoCorrectionResponse(
undone_items=["工号"],
restored_values={"工号": "12345"},
snapshot_id=1,
remaining_undo_count=4,
)
assert resp.remaining_undo_count == 4
assert "工号" in resp.undone_items
def test_version_diff_request_schema(self):
"""测试版本对比请求 Schema。"""
from app.schemas.automation import VersionDiffRequest
req = VersionDiffRequest(v1=1, v2=2)
assert req.v1 == 1
assert req.v2 == 2
def test_version_diff_response_schema(self):
"""测试版本对比响应 Schema。"""
from app.schemas.automation import VersionDiffResponse
resp = VersionDiffResponse(
item_key="工号",
v1=1,
v1_value="11111",
v2=2,
v2_value="22222",
changed=True,
)
assert resp.changed is True
assert resp.v1_value == "11111"
def test_compression_log_item_schema(self):
"""测试压缩日志项 Schema。"""
from app.schemas.automation import CompressionLogItem
log = CompressionLogItem(
id=1,
session_id="session-1",
tokens_before=8000,
tokens_after=3000,
compression_ratio=0.38,
task_node="扫描中",
duration_ms=150,
compression_level=2,
)
assert log.tokens_before == 8000
assert log.compression_level == 2
def test_compression_log_list_response_schema(self):
"""测试压缩日志列表响应 Schema。"""
from app.schemas.automation import CompressionLogListResponse
resp = CompressionLogListResponse(logs=[], total=0)
assert resp.total == 0
assert resp.logs == []
# =============================================================================
# 常量测试
# =============================================================================
class TestP2P3Constants:
"""P2/P3 常量测试。"""
def test_error_codes_exist(self):
"""测试新增错误码存在。"""
from app.constants import AutomationErrorCode
assert AutomationErrorCode.COMPRESSION_FAILED == 4017
assert AutomationErrorCode.UNDO_LIMIT_EXCEEDED == 4018
assert AutomationErrorCode.BATCH_CORRECT_FAILED == 4019
def test_error_messages_exist(self):
"""测试新增错误消息存在。"""
from app.constants import AUTOMATION_ERROR_MESSAGES, AutomationErrorCode
assert AutomationErrorCode.COMPRESSION_FAILED in AUTOMATION_ERROR_MESSAGES
assert AutomationErrorCode.UNDO_LIMIT_EXCEEDED in AUTOMATION_ERROR_MESSAGES
assert AutomationErrorCode.BATCH_CORRECT_FAILED in AUTOMATION_ERROR_MESSAGES
def test_error_message_content(self):
"""测试错误消息内容。"""
from app.constants import automation_error_message, AutomationErrorCode
msg = automation_error_message(AutomationErrorCode.UNDO_LIMIT_EXCEEDED)
assert "超限" in msg
assert "撤销" in msg
File diff suppressed because it is too large Load Diff
+895
View File
@@ -0,0 +1,895 @@
# =============================================================================
# 企微IT智能服务台 — 代办事项真实数据源集成 测试
# =============================================================================
# 测试覆盖:
# 1. ITSM 签名工具 (ITSMSigner)
# 2. TodoAggregatorService — 聚合服务(缓存/并行/容错/排序/过滤/详情路由)
# 3. ApprovalTodoService — 企微审批数据源(列表/过滤/映射/ID格式)
# 4. ITSMService — ITSM 工单数据源(列表/详情/无凭证/ID格式)
# 5. API 端点 — GET /todo-items, GET /todo-items/{id}, PUT status
# 6. Schema 验证 — VALID_TODO_TYPES 移除 device
# =============================================================================
import hashlib
import json
import logging
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import quote_plus
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from pydantic import ValidationError
# --- 临时补丁:itsm_service.py / todo_source_service.py / approval.py 中
# httpx.Timeout(connect=10.0, read=30.0) 在当前 httpx 版本下会抛 ValueError
# (要求 default 或全部 4 个参数)。
# 此补丁将部分参数的 Timeout 调用退化为 default=30s,不影响正常 Timeout 使用。
# ⚠️ 这是源码 bug 的临时绕过,已报告工程师修复。
import httpx as _httpx_mod
_UNSET = object() # 哨兵:区分"未传参"与 None
_orig_timeout_init = _httpx_mod.Timeout.__init__
def _compat_timeout_init(
self, timeout=_UNSET, *, connect=_UNSET, read=_UNSET, write=_UNSET, pool=_UNSET
):
"""兼容旧版 httpx.Timeout 部分参数调用方式。
当调用方只传了部分 kwargs(如 Timeout(connect=10, read=30))而未传 default 时,
退化为 Timeout(30.0) 以绕过新版 httpx 的校验。
其他正常调用(Timeout(5.0) / Timeout())原样透传。
"""
has_individual = any(v is not _UNSET for v in (connect, read, write, pool))
if has_individual and timeout is _UNSET:
# 源码 bug 场景:Timeout(connect=10, read=30) → 退化为 default
_orig_timeout_init(self, 30.0)
elif timeout is _UNSET:
_orig_timeout_init(self)
else:
_orig_timeout_init(self, timeout)
_httpx_mod.Timeout.__init__ = _compat_timeout_init
# --- 补丁结束 ---
from app.config import settings
from app.schemas.todo_item import VALID_TODO_TYPES, TodoItemCreate
from app.services.itsm_service import ITSMService, _itsm_priority_to_todo
from app.services.todo_aggregator_service import CACHE_TTL, PRIORITY_ORDER, TodoAggregatorService
from app.services.todo_source_service import ApprovalTodoService
from app.utils.itsm_signer import ITSMSigner
# =============================================================================
# 辅助:带 keys() 支持的 Mock Redis
# =============================================================================
class TestRedis:
"""内存字典 Redis mock,支持 get/setex/delete/keys/close。"""
__test__ = False # 告知 pytest 不要收集此类作为测试用例
def __init__(self):
self._data: Dict[str, str] = {}
async def get(self, key: str) -> Optional[bytes]:
value = self._data.get(key)
if value is not None:
return value.encode("utf-8") if isinstance(value, str) else value
return None
async def setex(self, name: str, time: int, value: str) -> None:
self._data[name] = value
async def set(self, name: str, value: str, **kwargs) -> Optional[bool]:
self._data[name] = value
return None
async def delete(self, *names) -> int:
count = 0
for name in names:
if name in self._data:
del self._data[name]
count += 1
return count
async def exists(self, *keys) -> int:
return sum(1 for k in keys if k in self._data)
async def keys(self, pattern: str) -> list:
import fnmatch
return [k for k in self._data if fnmatch.fnmatch(k, pattern)]
async def close(self) -> None:
pass
# =============================================================================
# 测试数据常量
# =============================================================================
AGENT_USERID = "test_agent_001"
# 企微审批详情 mock(当前审批人 = AGENT_USERID
APPROVAL_DETAIL_MINE = {
"errcode": 0,
"errmsg": "ok",
"info": {
"sp_no": "202607110001",
"sp_name": "资产领用登记",
"sp_status": 1,
"template_id": "C4c8qt31AbSHwN9MuaFhYXt4Qwsx6ZLCftAFh6X1w",
"apply_time": 1720656000,
"applyer": {"userid": "applicant_001", "partyid": "1"},
"sp_record": [
{
"status": 1,
"type": 1,
"approverattr": 1,
"approver": [{"userid": AGENT_USERID, "partyid": "2"}],
}
],
},
}
# 企微审批详情 mock(当前审批人 = 其他坐席)
APPROVAL_DETAIL_OTHER = {
"errcode": 0,
"errmsg": "ok",
"info": {
"sp_no": "202607110002",
"sp_name": "资产借用申请",
"sp_status": 1,
"template_id": "3TmACnFs8oqgYcasxVh4BfSMGNX7p9sb6ydBX77mK",
"apply_time": 1720656000,
"applyer": {"userid": "applicant_002", "partyid": "1"},
"sp_record": [
{
"status": 1,
"type": 1,
"approverattr": 1,
"approver": [{"userid": "other_agent", "partyid": "3"}],
}
],
},
}
# ITSM 工单详情 mock
ITSM_DETAIL = {
"process_instance_id": "12345",
"title": "网络故障报修",
"priority": "urgent",
"status": "pending",
"creator": "user_001",
"executor": AGENT_USERID,
"created_at": "2026-07-11T10:00:00Z",
"updated_at": "2026-07-11T10:30:00Z",
}
# ITSM API 成功响应
ITSM_API_RESPONSE = {
"code": 20000,
"message": "success",
"data": ITSM_DETAIL,
}
def _make_httpx_mock(response_json: dict) -> AsyncMock:
"""创建 httpx.AsyncClient 的 mockpost 返回指定 JSON。"""
mock_response = MagicMock()
mock_response.json.return_value = response_json
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
return mock_client
# =============================================================================
# 1. ITSM 签名工具测试
# =============================================================================
class TestITSMSigner:
"""ITSMSigner 签名计算和 headers 生成测试。"""
def test_itsm_signer_compute_signature(self):
"""验证签名计算结果正确:sort → concat → quote_plus → sha1 → upper。"""
app_id = "test_app_id"
timestamp = "1700000000000"
app_secret = "test_secret"
biz_data = {"key": "value"}
result = ITSMSigner.compute_signature(app_id, timestamp, app_secret, biz_data)
# 1. 应为 40 字符大写 hexSHA1 hexdigest upper
assert len(result) == 40
assert result == result.upper()
assert all(c in "0123456789ABCDEF" for c in result)
# 2. 独立复现算法,验证一致性
sign_params = {
"appSecret": app_secret,
"appId": app_id,
"timestamp": timestamp,
"bizData": json.dumps(biz_data, ensure_ascii=False),
}
sorted_params = sorted(sign_params.items(), key=lambda x: x[0])
canonicalized = "".join(str(v) for _, v in sorted_params)
quoted = quote_plus(canonicalized)
expected = hashlib.sha1(quoted.encode("utf-8")).hexdigest().upper()
assert result == expected
def test_itsm_signer_compute_signature_deterministic(self):
"""相同输入应产生相同签名(确定性)。"""
args = ("app1", "1700000000000", "secret1", {"a": 1})
sig1 = ITSMSigner.compute_signature(*args)
sig2 = ITSMSigner.compute_signature(*args)
assert sig1 == sig2
def test_itsm_signer_compute_signature_different_input(self):
"""不同输入应产生不同签名。"""
sig1 = ITSMSigner.compute_signature("app1", "ts1", "secret1", {"a": 1})
sig2 = ITSMSigner.compute_signature("app2", "ts1", "secret1", {"a": 1})
assert sig1 != sig2
def test_itsm_signer_get_headers(self):
"""验证生成的 headers 包含 appId/timestamp/sign/Content-Type。"""
app_id = "test_app_id"
app_secret = "test_secret"
biz_data = {"key": "value"}
headers = ITSMSigner.get_headers(app_id, app_secret, biz_data)
# 验证必需的 header 字段
assert "appId" in headers
assert "timestamp" in headers
assert "sign" in headers
assert "Content-Type" in headers
# 验证值
assert headers["appId"] == app_id
assert headers["Content-Type"] == "application/json"
assert len(headers["timestamp"]) > 0 # 非空时间戳
assert len(headers["sign"]) == 40 # SHA1 hex
# 验证 sign 与 compute_signature 一致
expected_sign = ITSMSigner.compute_signature(
app_id, headers["timestamp"], app_secret, biz_data
)
assert headers["sign"] == expected_sign
# =============================================================================
# 2. TodoAggregatorService 测试
# =============================================================================
class TestTodoAggregatorService:
"""聚合服务测试:缓存/并行查询/容错/排序/过滤/详情路由。"""
async def test_aggregator_get_todo_list_cache_hit(self):
"""缓存命中时直接返回,不调用外部 API。"""
redis = TestRedis()
cached = {"items": [{"id": "approval:123", "type": "approval", "priority": "high"}], "total": 1}
cache_key = TodoAggregatorService._cache_key(AGENT_USERID, None)
await redis.setex(cache_key, CACHE_TTL, json.dumps(cached, ensure_ascii=False))
aggregator = TodoAggregatorService(redis)
with patch("app.services.todo_aggregator_service.ApprovalTodoService") as MockAppr:
with patch("app.services.todo_aggregator_service.ITSMService") as MockITSM:
result = await aggregator.get_todo_list(AGENT_USERID, None)
# 缓存命中,不应创建数据源 Service
MockAppr.assert_not_called()
MockITSM.assert_not_called()
assert result["cached"] is True
assert result["total"] == 1
assert result["items"][0]["id"] == "approval:123"
async def test_aggregator_get_todo_list_cache_miss(self):
"""缓存未命中时并行查询两个数据源。"""
redis = TestRedis()
aggregator = TodoAggregatorService(redis)
approval_items = [{"id": "approval:001", "type": "approval", "priority": "high"}]
itsm_items = [{"id": "ticket:001", "type": "ticket", "priority": "normal"}]
with patch("app.services.todo_aggregator_service.ApprovalTodoService") as MockAppr:
with patch("app.services.todo_aggregator_service.ITSMService") as MockITSM:
mock_appr = AsyncMock()
mock_appr.get_todo_list.return_value = approval_items
MockAppr.return_value = mock_appr
mock_itsm = AsyncMock()
mock_itsm.get_todo_list.return_value = itsm_items
MockITSM.return_value = mock_itsm
result = await aggregator.get_todo_list(AGENT_USERID, None)
assert result["cached"] is False
assert result["total"] == 2
ids = [item["id"] for item in result["items"]]
assert "approval:001" in ids
assert "ticket:001" in ids
# 验证缓存已写入
cache_key = TodoAggregatorService._cache_key(AGENT_USERID, None)
cached_raw = await redis.get(cache_key)
assert cached_raw is not None
async def test_aggregator_get_todo_list_one_source_fails(self):
"""一个数据源失败时另一个仍正常返回(容错)。"""
redis = TestRedis()
aggregator = TodoAggregatorService(redis)
itsm_items = [{"id": "ticket:001", "type": "ticket", "priority": "urgent"}]
with patch("app.services.todo_aggregator_service.ApprovalTodoService") as MockAppr:
with patch("app.services.todo_aggregator_service.ITSMService") as MockITSM:
mock_appr = AsyncMock()
mock_appr.get_todo_list.side_effect = Exception("企微 API 不可达")
MockAppr.return_value = mock_appr
mock_itsm = AsyncMock()
mock_itsm.get_todo_list.return_value = itsm_items
MockITSM.return_value = mock_itsm
result = await aggregator.get_todo_list(AGENT_USERID, None)
# 审批失败但工单正常返回
assert result["total"] == 1
assert result["items"][0]["id"] == "ticket:001"
async def test_aggregator_get_todo_list_priority_sort(self):
"""返回结果按 urgent → high → normal 排序。"""
redis = TestRedis()
aggregator = TodoAggregatorService(redis)
# 故意打乱顺序
approval_items = [
{"id": "approval:1", "type": "approval", "priority": "normal"},
{"id": "approval:2", "type": "approval", "priority": "urgent"},
{"id": "approval:3", "type": "approval", "priority": "high"},
]
itsm_items = [
{"id": "ticket:1", "type": "ticket", "priority": "high"},
{"id": "ticket:2", "type": "ticket", "priority": "urgent"},
]
with patch("app.services.todo_aggregator_service.ApprovalTodoService") as MockAppr:
with patch("app.services.todo_aggregator_service.ITSMService") as MockITSM:
mock_appr = AsyncMock()
mock_appr.get_todo_list.return_value = approval_items
MockAppr.return_value = mock_appr
mock_itsm = AsyncMock()
mock_itsm.get_todo_list.return_value = itsm_items
MockITSM.return_value = mock_itsm
result = await aggregator.get_todo_list(AGENT_USERID, None)
priorities = [item["priority"] for item in result["items"]]
# urgent 应在前,normal 应在后
urgent_idx = [i for i, p in enumerate(priorities) if p == "urgent"]
high_idx = [i for i, p in enumerate(priorities) if p == "high"]
normal_idx = [i for i, p in enumerate(priorities) if p == "normal"]
assert all(i < min(high_idx) for i in urgent_idx) if urgent_idx and high_idx else True
assert all(i < min(normal_idx) for i in high_idx) if high_idx and normal_idx else True
async def test_aggregator_get_todo_list_type_filter(self):
"""type=approval 只返回审批类型。"""
redis = TestRedis()
aggregator = TodoAggregatorService(redis)
approval_items = [{"id": "approval:1", "type": "approval", "priority": "high"}]
itsm_items = [{"id": "ticket:1", "type": "ticket", "priority": "urgent"}]
with patch("app.services.todo_aggregator_service.ApprovalTodoService") as MockAppr:
with patch("app.services.todo_aggregator_service.ITSMService") as MockITSM:
mock_appr = AsyncMock()
mock_appr.get_todo_list.return_value = approval_items
MockAppr.return_value = mock_appr
mock_itsm = AsyncMock()
mock_itsm.get_todo_list.return_value = itsm_items
MockITSM.return_value = mock_itsm
result = await aggregator.get_todo_list(AGENT_USERID, "approval")
assert result["total"] == 1
assert all(item["type"] == "approval" for item in result["items"])
async def test_aggregator_get_todo_detail_approval(self):
"""详情查询路由到 ApprovalTodoService。"""
redis = TestRedis()
aggregator = TodoAggregatorService(redis)
with patch("app.services.todo_aggregator_service.ApprovalTodoService") as MockAppr:
mock_appr = AsyncMock()
mock_appr.get_todo_detail.return_value = {"id": "approval:202607110001", "type": "approval"}
MockAppr.return_value = mock_appr
result = await aggregator.get_todo_detail(
AGENT_USERID, "approval:202607110001", "approval"
)
mock_appr.get_todo_detail.assert_called_once_with("202607110001")
assert result is not None
assert result["id"] == "approval:202607110001"
async def test_aggregator_get_todo_detail_ticket(self):
"""详情查询路由到 ITSMService。"""
redis = TestRedis()
aggregator = TodoAggregatorService(redis)
with patch("app.services.todo_aggregator_service.ITSMService") as MockITSM:
mock_itsm = AsyncMock()
mock_itsm.get_todo_detail.return_value = {"id": "ticket:12345", "type": "ticket"}
MockITSM.return_value = mock_itsm
result = await aggregator.get_todo_detail(
AGENT_USERID, "ticket:12345", "ticket"
)
mock_itsm.get_todo_detail.assert_called_once_with("12345")
assert result is not None
assert result["id"] == "ticket:12345"
async def test_aggregator_get_todo_detail_auto_parse_type(self):
"""未提供 todo_type 时从 item_id 自动解析类型前缀。"""
redis = TestRedis()
aggregator = TodoAggregatorService(redis)
with patch("app.services.todo_aggregator_service.ApprovalTodoService") as MockAppr:
mock_appr = AsyncMock()
mock_appr.get_todo_detail.return_value = {"id": "approval:001"}
MockAppr.return_value = mock_appr
result = await aggregator.get_todo_detail(AGENT_USERID, "approval:001")
assert result is not None
mock_appr.get_todo_detail.assert_called_once_with("001")
async def test_aggregator_invalidate_cache(self):
"""缓存失效后重新查询。"""
redis = TestRedis()
# 预填充缓存
await redis.setex("todo:cache:test_agent:all", 45, '{"items":[],"total":0}')
await redis.setex("todo:cache:test_agent:approval", 45, '{"items":[],"total":0}')
aggregator = TodoAggregatorService(redis)
await aggregator._invalidate_cache("test_agent")
# 验证缓存已清除
assert await redis.get("todo:cache:test_agent:all") is None
assert await redis.get("todo:cache:test_agent:approval") is None
def test_aggregator_cache_key_format(self):
"""验证缓存 key 格式:todo:cache:{userid}:{type_or_all}"""
key_all = TodoAggregatorService._cache_key("user1", None)
assert key_all == "todo:cache:user1:all"
key_approval = TodoAggregatorService._cache_key("user1", "approval")
assert key_approval == "todo:cache:user1:approval"
key_ticket = TodoAggregatorService._cache_key("user1", "ticket")
assert key_ticket == "todo:cache:user1:ticket"
# =============================================================================
# 3. ApprovalTodoService 测试
# =============================================================================
class TestApprovalTodoService:
"""企微审批数据源测试。"""
async def test_approval_get_todo_list(self):
"""mock 企微 API 返回,验证 getapprovaldata → getapprovaldetail → filter → map 流程。"""
redis = TestRedis()
service = ApprovalTodoService(agent_userid=AGENT_USERID, redis=redis)
# Mock token manager
mock_token_mgr = AsyncMock()
mock_token_mgr.get_token.return_value = "fake_access_token"
mock_token_mgr.close = AsyncMock()
# Mock getapprovaldata response
getapprovaldata_resp = {
"errcode": 0,
"data": [
{"sp_no": "202607110001"},
{"sp_no": "202607110002"},
],
"next_cursor": 0,
}
mock_http = _make_httpx_mock(getapprovaldata_resp)
with patch("app.services.todo_source_service.ApprovalTokenManager", return_value=mock_token_mgr):
with patch("app.services.todo_source_service.httpx.AsyncClient", return_value=mock_http):
with patch(
"app.services.todo_source_service.get_approval_detail",
new_callable=AsyncMock,
side_effect=[APPROVAL_DETAIL_MINE, APPROVAL_DETAIL_OTHER],
):
result = await service.get_todo_list()
# 只有 APPROVAL_DETAIL_MINE 的当前审批人是 AGENT_USERID
assert len(result) == 1
assert result[0]["id"] == "approval:202607110001"
assert result[0]["type"] == "approval"
async def test_approval_get_todo_list_token_fail(self):
"""access_token 获取失败时返回空列表。"""
redis = TestRedis()
service = ApprovalTodoService(agent_userid=AGENT_USERID, redis=redis)
mock_token_mgr = AsyncMock()
mock_token_mgr.get_token.return_value = ""
mock_token_mgr.close = AsyncMock()
with patch("app.services.todo_source_service.ApprovalTokenManager", return_value=mock_token_mgr):
result = await service.get_todo_list()
assert result == []
async def test_approval_filter_by_current_approver(self):
"""验证只返回当前审批人是当前坐席的审批单。"""
redis = TestRedis()
service = ApprovalTodoService(agent_userid=AGENT_USERID, redis=redis)
details = [APPROVAL_DETAIL_MINE, APPROVAL_DETAIL_OTHER]
filtered = service._filter_by_current_approver(details)
assert len(filtered) == 1
assert filtered[0]["info"]["sp_no"] == "202607110001"
async def test_approval_filter_empty_list(self):
"""空列表过滤返回空列表。"""
redis = TestRedis()
service = ApprovalTodoService(agent_userid=AGENT_USERID, redis=redis)
assert service._filter_by_current_approver([]) == []
async def test_approval_map_to_todo_item(self):
"""验证企微审批详情正确映射为 TodoItemData 格式。"""
redis = TestRedis()
service = ApprovalTodoService(agent_userid=AGENT_USERID, redis=redis)
mapped = service._map_to_todo_item(APPROVAL_DETAIL_MINE)
assert mapped["id"] == "approval:202607110001"
assert mapped["type"] == "approval"
assert mapped["title"] == "资产领用登记"
assert mapped["priority"] == "high"
assert mapped["status"] == "pending"
assert mapped["assigned_agent_id"] == AGENT_USERID
# 验证 description 包含关键字段
desc = mapped["description"]
assert desc["sp_no"] == "202607110001"
assert desc["applicant"] == "applicant_001"
assert desc["sp_status"] == 1
assert desc["current_approver"] == AGENT_USERID
async def test_approval_id_format(self):
"""验证 ID 格式为 "approval:{sp_no}""""
redis = TestRedis()
service = ApprovalTodoService(agent_userid=AGENT_USERID, redis=redis)
mapped = service._map_to_todo_item(APPROVAL_DETAIL_MINE)
assert mapped["id"] == "approval:202607110001"
assert mapped["id"].startswith("approval:")
async def test_approval_get_todo_detail(self):
"""验证详情查询流程:get_token → get_approval_detail → map。"""
redis = TestRedis()
service = ApprovalTodoService(agent_userid=AGENT_USERID, redis=redis)
mock_token_mgr = AsyncMock()
mock_token_mgr.get_token.return_value = "fake_access_token"
mock_token_mgr.close = AsyncMock()
with patch("app.services.todo_source_service.ApprovalTokenManager", return_value=mock_token_mgr):
with patch(
"app.services.todo_source_service.get_approval_detail",
new_callable=AsyncMock,
return_value=APPROVAL_DETAIL_MINE,
):
result = await service.get_todo_detail("202607110001")
assert result is not None
assert result["id"] == "approval:202607110001"
assert result["type"] == "approval"
# =============================================================================
# 4. ITSMService 测试
# =============================================================================
class TestITSMService:
"""ITSM 工单数据源测试。"""
async def test_itsm_get_todo_list_not_implemented(self):
"""列表方法返回空列表(API 尚未实现)。"""
redis = TestRedis()
with patch.object(settings, "itsm_app_id", "test_app_id"):
with patch.object(settings, "itsm_app_secret", "test_secret"):
with patch.object(settings, "itsm_base_url", "https://test-itsm.example.com"):
service = ITSMService(agent_userid=AGENT_USERID, redis=redis)
result = await service.get_todo_list()
assert result == []
assert isinstance(result, list)
async def test_itsm_get_todo_detail(self):
"""mock ITSM API 返回,验证详情查询和映射。"""
redis = TestRedis()
mock_http = _make_httpx_mock(ITSM_API_RESPONSE)
with patch.object(settings, "itsm_app_id", "test_app_id"):
with patch.object(settings, "itsm_app_secret", "test_secret"):
with patch.object(settings, "itsm_base_url", "https://test-itsm.example.com"):
service = ITSMService(agent_userid=AGENT_USERID, redis=redis)
with patch("app.services.itsm_service.httpx.AsyncClient", return_value=mock_http):
result = await service.get_todo_detail("12345")
assert result is not None
assert result["id"] == "ticket:12345"
assert result["type"] == "ticket"
assert result["title"] == "网络故障报修"
assert result["priority"] == "urgent"
assert result["status"] == "pending"
async def test_itsm_no_credentials(self):
"""itsm_app_id 为空时返回空 + 日志告警。"""
redis = TestRedis()
with patch.object(settings, "itsm_app_id", ""):
service = ITSMService(agent_userid=AGENT_USERID, redis=redis)
# 列表返回空
list_result = await service.get_todo_list()
assert list_result == []
# 详情返回 None
detail_result = await service.get_todo_detail("12345")
assert detail_result is None
async def test_itsm_id_format(self):
"""验证 ID 格式为 "ticket:{process_instance_id}""""
redis = TestRedis()
mock_http = _make_httpx_mock(ITSM_API_RESPONSE)
with patch.object(settings, "itsm_app_id", "test_app_id"):
with patch.object(settings, "itsm_app_secret", "test_secret"):
with patch.object(settings, "itsm_base_url", "https://test-itsm.example.com"):
service = ITSMService(agent_userid=AGENT_USERID, redis=redis)
with patch("app.services.itsm_service.httpx.AsyncClient", return_value=mock_http):
result = await service.get_todo_detail("12345")
assert result is not None
assert result["id"] == "ticket:12345"
assert result["id"].startswith("ticket:")
async def test_itsm_get_todo_detail_api_error(self):
"""ITSM API 返回错误码时返回 None。"""
redis = TestRedis()
error_response = {"code": 50000, "message": "internal error", "data": None}
mock_http = _make_httpx_mock(error_response)
with patch.object(settings, "itsm_app_id", "test_app_id"):
with patch.object(settings, "itsm_app_secret", "test_secret"):
with patch.object(settings, "itsm_base_url", "https://test-itsm.example.com"):
service = ITSMService(agent_userid=AGENT_USERID, redis=redis)
with patch("app.services.itsm_service.httpx.AsyncClient", return_value=mock_http):
result = await service.get_todo_detail("12345")
assert result is None
def test_itsm_priority_mapping(self):
"""验证 ITSM 优先级映射到 urgent/high/normal。"""
assert _itsm_priority_to_todo("urgent") == "urgent"
assert _itsm_priority_to_todo("紧急") == "urgent"
assert _itsm_priority_to_todo("1") == "urgent"
assert _itsm_priority_to_todo("P0") == "urgent"
assert _itsm_priority_to_todo("high") == "high"
assert _itsm_priority_to_todo("") == "high"
assert _itsm_priority_to_todo("2") == "high"
assert _itsm_priority_to_todo("normal") == "normal"
assert _itsm_priority_to_todo(None) == "normal"
assert _itsm_priority_to_todo("unknown") == "normal"
# =============================================================================
# 5. API 端点测试
# =============================================================================
class TestTodoItemsAPI:
"""todo-items API 端点测试。"""
@pytest_asyncio.fixture
async def todo_client(self, db_session, mock_redis):
"""创建带 mock 认证的测试客户端。"""
from app.api.agents import get_current_agent
from app.main import create_app
from app.models.agent import Agent
app = create_app()
# Mock agent
mock_agent = MagicMock()
mock_agent.user_id = "todo_test_agent"
mock_agent.name = "Todo Test Agent"
app.dependency_overrides[get_current_agent] = lambda: mock_agent
mock_redis_instance = AsyncMock()
mock_redis_instance.close = AsyncMock()
with patch("app.api.todo_items._get_redis", return_value=mock_redis_instance):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
async def test_api_list_todo_items(self, todo_client):
"""GET /todo-items 返回正确格式。"""
mock_result = {
"items": [
{"id": "approval:001", "type": "approval", "priority": "high", "title": "审批1"},
{"id": "ticket:001", "type": "ticket", "priority": "normal", "title": "工单1"},
],
"total": 2,
"cached": False,
}
with patch.object(TodoAggregatorService, "get_todo_list", new_callable=AsyncMock) as mock_gl:
mock_gl.return_value = mock_result
response = await todo_client.get("/todo-items")
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"]["total"] == 2
assert len(data["data"]["items"]) == 2
async def test_api_list_todo_items_with_type_filter(self, todo_client):
"""type=approval 过滤。"""
mock_result = {
"items": [{"id": "approval:001", "type": "approval", "priority": "high"}],
"total": 1,
"cached": False,
}
with patch.object(TodoAggregatorService, "get_todo_list", new_callable=AsyncMock) as mock_gl:
mock_gl.return_value = mock_result
response = await todo_client.get("/todo-items?type=approval")
assert response.status_code == 200
data = response.json()
assert data["data"]["total"] == 1
assert data["data"]["items"][0]["type"] == "approval"
# 验证 type 参数传递正确
call_kwargs = mock_gl.call_args
assert call_kwargs.kwargs.get("todo_type") == "approval" or call_kwargs[1].get("todo_type") == "approval"
async def test_api_list_todo_items_with_force(self, todo_client):
"""_force=1 跳过缓存。"""
mock_result = {"items": [], "total": 0, "cached": False}
with patch.object(TodoAggregatorService, "_invalidate_cache", new_callable=AsyncMock) as mock_inv:
with patch.object(TodoAggregatorService, "get_todo_list", new_callable=AsyncMock) as mock_gl:
mock_gl.return_value = mock_result
response = await todo_client.get("/todo-items?_force=1")
assert response.status_code == 200
# 验证缓存失效被调用
mock_inv.assert_called_once_with("todo_test_agent")
async def test_api_get_todo_item(self, todo_client):
"""GET /todo-items/{id} 返回详情。"""
mock_detail = {
"id": "approval:202607110001",
"type": "approval",
"title": "资产领用登记",
"priority": "high",
"status": "pending",
}
with patch.object(TodoAggregatorService, "get_todo_detail", new_callable=AsyncMock) as mock_gd:
mock_gd.return_value = mock_detail
response = await todo_client.get("/todo-items/approval:202607110001")
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"]["id"] == "approval:202607110001"
assert data["data"]["type"] == "approval"
async def test_api_get_todo_item_not_found(self, todo_client):
"""不存在的 ID 返回错误。"""
with patch.object(TodoAggregatorService, "get_todo_detail", new_callable=AsyncMock) as mock_gd:
mock_gd.return_value = None
response = await todo_client.get("/todo-items/approval:nonexistent")
data = response.json()
assert data["code"] == 1003
async def test_api_update_status_display_only(self, todo_client):
"""PUT status 返回"请在原系统中操作"提示。"""
response = await todo_client.put(
"/todo-items/approval:202607110001/status",
json={"status": "resolved"},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"]["mode"] == "display_only"
assert "企微审批" in data["data"]["message"]
async def test_api_update_status_ticket(self, todo_client):
"""工单类型的状态更新提示 ITSM。"""
response = await todo_client.put(
"/todo-items/ticket:12345/status",
json={"status": "processing"},
)
assert response.status_code == 200
data = response.json()
assert data["data"]["mode"] == "display_only"
assert "ITSM" in data["data"]["message"]
async def test_api_update_status_invalid(self, todo_client):
"""无效状态值返回错误。"""
response = await todo_client.put(
"/todo-items/approval:001/status",
json={"status": "invalid_status"},
)
data = response.json()
assert data["code"] == 1001
# =============================================================================
# 6. Schema 验证测试
# =============================================================================
class TestTodoItemSchema:
"""Schema 验证测试:VALID_TODO_TYPES 移除 device。"""
def test_schema_valid_types(self):
"""VALID_TODO_TYPES 只包含 ticket/approval。"""
assert VALID_TODO_TYPES == {"ticket", "approval"}
assert "device" not in VALID_TODO_TYPES
assert len(VALID_TODO_TYPES) == 2
def test_schema_reject_device_type(self):
"""type=device 被拒绝。"""
with pytest.raises(ValidationError) as exc_info:
TodoItemCreate(type="device", title="测试待办")
assert "device" in str(exc_info.value)
def test_schema_accept_ticket_type(self):
"""type=ticket 被接受。"""
item = TodoItemCreate(type="ticket", title="测试工单")
assert item.type == "ticket"
def test_schema_accept_approval_type(self):
"""type=approval 被接受。"""
item = TodoItemCreate(type="approval", title="测试审批")
assert item.type == "approval"
def test_schema_default_type(self):
"""默认 type 为 ticket。"""
item = TodoItemCreate(title="测试")
assert item.type == "ticket"