Files
wecom_it_smart_desk/backend/tests/test_todo_integration.py
T
Simon bea288e414 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
2026-07-11 23:13:10 +08:00

896 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微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"