896 lines
36 KiB
Python
896 lines
36 KiB
Python
|
|
# =============================================================================
|
|||
|
|
# 企微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 的 mock,post 返回指定 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 字符大写 hex(SHA1 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"
|