Files

896 lines
36 KiB
Python
Raw Permalink Normal View History

# =============================================================================
# 企微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"