bea288e414
== 已部署上线 (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
726 lines
29 KiB
Python
726 lines
29 KiB
Python
# =============================================================================
|
||
# IT智能服务台 — 审批意图检测(/approval/detect-intent)测试
|
||
# =============================================================================
|
||
# 测试覆盖:
|
||
# 1. 关键词预过滤逻辑(_keyword_prefilter 单元测试)
|
||
# 2. 降级兜底逻辑(_fallback_detect 单元测试)
|
||
# 3. 端点集成测试:
|
||
# a. 关键词未命中 → 直接返回 false(不调 Dify)
|
||
# b. 关键词命中 + Dify 高置信度 → is_approval_request=true
|
||
# c. 关键词命中 + Dify 低置信度 → is_approval_request=false
|
||
# d. Dify 调用失败 → 降级兜底
|
||
# e. Dify 未配置 → 降级兜底
|
||
# f. Dify 返回 is_approval=false → is_approval_request=false
|
||
# 4. /approval/keywords 端点测试
|
||
# 5. /approval/jump 端点测试
|
||
#
|
||
# Round 2 变更:所有端点已改用 success_response() 包装,
|
||
# 响应格式为 {code: 0, data: {...}, message: "success"}
|
||
# 测试断言已适配新格式:data["data"]["field"] 而非 data["field"]
|
||
# =============================================================================
|
||
|
||
import pytest
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
from app.api.approval import (
|
||
_keyword_prefilter,
|
||
_fallback_detect,
|
||
KEYWORD_TO_APPROVAL_TYPE,
|
||
APPROVAL_PREFILTER_KEYWORDS,
|
||
)
|
||
from app.config import settings
|
||
|
||
|
||
# =============================================================================
|
||
# 单元测试:_keyword_prefilter
|
||
# =============================================================================
|
||
|
||
class TestKeywordPrefilter:
|
||
"""测试关键词预过滤函数。"""
|
||
|
||
def test_keyword_hit_device(self):
|
||
"""包含设备相关关键词时返回 True。"""
|
||
assert _keyword_prefilter("我需要申请一台电脑") is True
|
||
|
||
def test_keyword_hit_vpn(self):
|
||
"""包含 VPN 关键词时返回 True(大小写不敏感)。"""
|
||
assert _keyword_prefilter("VPN 连不上了") is True
|
||
assert _keyword_prefilter("vpn 连不上了") is True
|
||
|
||
def test_keyword_hit_software(self):
|
||
"""包含软件相关关键词时返回 True。"""
|
||
assert _keyword_prefilter("想申请一个软件") is True
|
||
|
||
def test_keyword_hit_generic(self):
|
||
"""包含通用关键词'申请'时返回 True。"""
|
||
assert _keyword_prefilter("我想申请一个东西") is True
|
||
|
||
def test_keyword_miss_normal_message(self):
|
||
"""普通对话消息不包含审批关键词时返回 False。"""
|
||
assert _keyword_prefilter("你好,今天天气怎么样") is False
|
||
assert _keyword_prefilter("谢谢,问题解决了") is False
|
||
|
||
def test_keyword_miss_empty_string(self):
|
||
"""空字符串返回 False。"""
|
||
assert _keyword_prefilter("") is False
|
||
|
||
def test_keyword_miss_none(self):
|
||
"""None 返回 False。"""
|
||
assert _keyword_prefilter(None) is False # type: ignore[arg-type]
|
||
|
||
def test_keyword_case_insensitive(self):
|
||
"""关键词匹配大小写不敏感。"""
|
||
assert _keyword_prefilter("我的VPN坏了") is True
|
||
assert _keyword_prefilter("我的vpn坏了") is True
|
||
assert _keyword_prefilter("我的Vpn坏了") is True
|
||
|
||
def test_keyword_partial_match(self):
|
||
"""关键词是子串匹配('设备'在'设备申请'中也能命中)。"""
|
||
assert _keyword_prefilter("设备申请流程是什么") is True
|
||
|
||
def test_keyword_all_prefilter_keywords_work(self):
|
||
"""验证 APPROVAL_PREFILTER_KEYWORDS 中的每个关键词都能被命中。"""
|
||
for kw in APPROVAL_PREFILTER_KEYWORDS:
|
||
# 构造包含该关键词的文本
|
||
text = f"测试文本包含{kw}关键词"
|
||
assert _keyword_prefilter(text) is True, f"关键词 '{kw}' 未被命中"
|
||
|
||
|
||
# =============================================================================
|
||
# 单元测试:_fallback_detect
|
||
# =============================================================================
|
||
|
||
class TestFallbackDetect:
|
||
"""测试降级兜底检测函数。"""
|
||
|
||
def test_fallback_returns_true_for_keyword_text(self):
|
||
"""包含关键词的文本,兜底返回 is_approval_request=True。"""
|
||
is_approval, confidence, approval_type = _fallback_detect("我需要申请电脑")
|
||
assert is_approval is True
|
||
assert confidence == 0.6
|
||
|
||
def test_fallback_returns_correct_type_device(self):
|
||
"""设备相关关键词映射到'设备申请'。"""
|
||
_, _, approval_type = _fallback_detect("我要申请电脑")
|
||
assert approval_type == "设备申请"
|
||
|
||
def test_fallback_returns_correct_type_vpn(self):
|
||
"""VPN 关键词映射到'账号权限申请'。"""
|
||
_, _, approval_type = _fallback_detect("VPN连不上了")
|
||
assert approval_type == "账号权限申请"
|
||
|
||
def test_fallback_returns_correct_type_software(self):
|
||
"""软件关键词映射到'软件服务申请'。"""
|
||
_, _, approval_type = _fallback_detect("想安装一个软件")
|
||
assert approval_type == "软件服务申请"
|
||
|
||
def test_fallback_returns_correct_type_disposal(self):
|
||
"""资产处置关键词映射到'资产处置申请'。
|
||
|
||
注意:测试文本不能同时包含'电脑'(→设备申请)等排在'报废'前面的关键词,
|
||
否则 _fallback_detect 会因字典遍历顺序返回先匹配的类型。
|
||
"""
|
||
_, _, approval_type = _fallback_detect("这台机器要报废")
|
||
assert approval_type == "资产处置申请"
|
||
|
||
def test_fallback_returns_correct_type_office_supplies(self):
|
||
"""办公用品关键词映射到'办公用品申请'。"""
|
||
_, _, approval_type = _fallback_detect("办公用品超额了")
|
||
assert approval_type == "办公用品申请"
|
||
|
||
def test_fallback_confidence_is_0_6(self):
|
||
"""兜底置信度固定为 0.6(低于阈值 0.7)。"""
|
||
_, confidence, _ = _fallback_detect("申请设备")
|
||
assert confidence == 0.6
|
||
|
||
def test_fallback_empty_text_returns_true_no_type(self):
|
||
"""空文本兜底仍返回 True(预过滤已通过),但 approval_type 为 None。"""
|
||
is_approval, confidence, approval_type = _fallback_detect("")
|
||
assert is_approval is True
|
||
assert confidence == 0.6
|
||
assert approval_type is None
|
||
|
||
def test_fallback_generic_keyword_no_type_mapping(self):
|
||
"""'申请'在预过滤列表但不在 KEYWORD_TO_APPROVAL_TYPE 中,兜底 approval_type 为 None。"""
|
||
# "申请" 在 APPROVAL_PREFILTER_KEYWORDS 中,但不在 KEYWORD_TO_APPROVAL_TYPE 中
|
||
is_approval, _, approval_type = _fallback_detect("申请")
|
||
assert is_approval is True
|
||
assert approval_type is None
|
||
|
||
def test_fallback_first_match_wins(self):
|
||
"""兜底遍历 KEYWORD_TO_APPROVAL_TYPE,命中第一个关键词即返回(字典有序)。"""
|
||
# 包含多个关键词的文本,应返回第一个匹配的类型
|
||
_, _, approval_type = _fallback_detect("设备和VPN都坏了")
|
||
# KEYWORD_TO_APPROVAL_TYPE 中"设备"排在"VPN"前面
|
||
assert approval_type is not None
|
||
assert approval_type in KEYWORD_TO_APPROVAL_TYPE.values()
|
||
|
||
|
||
# =============================================================================
|
||
# 集成测试:/approval/detect-intent 端点
|
||
# =============================================================================
|
||
# Round 2: 响应格式已改为 {code: 0, data: {...}, message: "success"}
|
||
# 所有断言通过 data["data"]["field"] 访问业务字段
|
||
# =============================================================================
|
||
|
||
class TestDetectApprovalIntentEndpoint:
|
||
"""测试 /approval/detect-intent API 端点。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_keyword_not_hit_returns_false_no_dify(self, client):
|
||
"""关键词未命中 → 直接返回 false,不调 Dify,source=keyword_prefilter。"""
|
||
response = await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "你好,今天天气怎么样"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
# 统一响应格式验证
|
||
assert data["code"] == 0
|
||
assert data["message"] == "success"
|
||
# 业务字段验证
|
||
inner = data["data"]
|
||
assert inner["is_approval_request"] is False
|
||
assert inner["confidence"] == 0.0
|
||
assert inner["approval_type"] is None
|
||
assert inner["source"] == "keyword_prefilter"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_keyword_not_hit_empty_text(self, client):
|
||
"""空文本 → 关键词未命中,返回 false。"""
|
||
response = await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": ""},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
inner = data["data"]
|
||
assert inner["is_approval_request"] is False
|
||
assert inner["source"] == "keyword_prefilter"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dify_high_confidence_returns_true(self, client, monkeypatch):
|
||
"""关键词命中 + Dify 返回高置信度(≥0.7) → is_approval_request=true, source=dify。"""
|
||
# 设置 Dify 配置(模拟已配置)
|
||
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
|
||
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
|
||
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
|
||
|
||
# Mock Dify 返回高置信度
|
||
mock_dify = AsyncMock(return_value={
|
||
"is_approval_request": True,
|
||
"confidence": 0.95,
|
||
"approval_type": "设备申请",
|
||
})
|
||
|
||
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
|
||
response = await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "我需要申请一台笔记本电脑"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
assert data["message"] == "success"
|
||
inner = data["data"]
|
||
assert inner["is_approval_request"] is True
|
||
assert inner["confidence"] == 0.95
|
||
assert inner["approval_type"] == "设备申请"
|
||
assert inner["source"] == "dify"
|
||
|
||
# 验证 Dify 被调用了一次
|
||
mock_dify.assert_called_once()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dify_confidence_at_threshold_returns_true(self, client, monkeypatch):
|
||
"""置信度恰好等于阈值(0.7) → is_approval_request=true(>= 判断)。"""
|
||
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
|
||
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
|
||
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
|
||
|
||
mock_dify = AsyncMock(return_value={
|
||
"is_approval_request": True,
|
||
"confidence": 0.7,
|
||
"approval_type": "账号权限申请",
|
||
})
|
||
|
||
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
|
||
response = await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "VPN 账号申请"},
|
||
)
|
||
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
inner = data["data"]
|
||
assert inner["is_approval_request"] is True
|
||
assert inner["confidence"] == 0.7
|
||
assert inner["source"] == "dify"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dify_low_confidence_returns_false(self, client, monkeypatch):
|
||
"""关键词命中 + Dify 返回低置信度(<0.7) → is_approval_request=false, source=dify。"""
|
||
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
|
||
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
|
||
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
|
||
|
||
mock_dify = AsyncMock(return_value={
|
||
"is_approval_request": True,
|
||
"confidence": 0.5,
|
||
"approval_type": "设备申请",
|
||
})
|
||
|
||
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
|
||
response = await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "我想申请一台电脑"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
inner = data["data"]
|
||
assert inner["is_approval_request"] is False
|
||
assert inner["confidence"] == 0.5
|
||
assert inner["approval_type"] == "设备申请"
|
||
assert inner["source"] == "dify"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dify_returns_is_approval_false(self, client, monkeypatch):
|
||
"""Dify 明确返回 is_approval_request=false → 端点返回 false。"""
|
||
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
|
||
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
|
||
|
||
mock_dify = AsyncMock(return_value={
|
||
"is_approval_request": False,
|
||
"confidence": 0.3,
|
||
"approval_type": None,
|
||
})
|
||
|
||
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
|
||
response = await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "我的电脑卡了"},
|
||
)
|
||
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
inner = data["data"]
|
||
assert inner["is_approval_request"] is False
|
||
assert inner["confidence"] == 0.3
|
||
assert inner["approval_type"] is None
|
||
assert inner["source"] == "dify"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dify_failure_fallback(self, client, monkeypatch):
|
||
"""关键词命中 + Dify 调用失败 → 降级兜底, source=fallback。"""
|
||
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
|
||
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
|
||
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.7)
|
||
|
||
# Mock Dify 抛出异常(模拟网络错误/超时)
|
||
mock_dify = AsyncMock(side_effect=Exception("Dify 服务不可达"))
|
||
|
||
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
|
||
response = await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "我需要申请一台电脑"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
inner = data["data"]
|
||
# 兜底返回 is_approval_request=True, confidence=0.6
|
||
assert inner["is_approval_request"] is True
|
||
assert inner["confidence"] == 0.6
|
||
assert inner["approval_type"] == "设备申请"
|
||
assert inner["source"] == "fallback"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dify_not_configured_fallback(self, client, monkeypatch):
|
||
"""Dify 未配置(base_url/api_key 为空)→ 降级兜底。"""
|
||
# 确保配置为空
|
||
monkeypatch.setattr(settings, "approval_dify_base_url", "")
|
||
monkeypatch.setattr(settings, "approval_dify_api_key", "")
|
||
|
||
response = await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "VPN 账号申请"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
inner = data["data"]
|
||
assert inner["is_approval_request"] is True
|
||
assert inner["confidence"] == 0.6
|
||
assert inner["approval_type"] == "账号权限申请"
|
||
assert inner["source"] == "fallback"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fallback_with_generic_keyword_no_type(self, client, monkeypatch):
|
||
"""兜底场景:'申请'在预过滤列表但无类型映射 → approval_type=None。"""
|
||
monkeypatch.setattr(settings, "approval_dify_base_url", "")
|
||
monkeypatch.setattr(settings, "approval_dify_api_key", "")
|
||
|
||
response = await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "我要申请一个东西"},
|
||
)
|
||
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
inner = data["data"]
|
||
assert inner["is_approval_request"] is True
|
||
assert inner["confidence"] == 0.6
|
||
assert inner["approval_type"] is None
|
||
assert inner["source"] == "fallback"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_custom_threshold_override(self, client, monkeypatch):
|
||
"""自定义阈值:阈值设为 0.5,Dify 返回 0.6 → is_approval_request=true。"""
|
||
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
|
||
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
|
||
monkeypatch.setattr(settings, "approval_confidence_threshold", 0.5)
|
||
|
||
mock_dify = AsyncMock(return_value={
|
||
"is_approval_request": True,
|
||
"confidence": 0.6,
|
||
"approval_type": "设备申请",
|
||
})
|
||
|
||
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
|
||
response = await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "申请设备"},
|
||
)
|
||
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
inner = data["data"]
|
||
# 阈值 0.5,置信度 0.6 >= 0.5 → True
|
||
assert inner["is_approval_request"] is True
|
||
assert inner["source"] == "dify"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_employee_id_passed_to_dify(self, client, monkeypatch):
|
||
"""验证 employee_id 被传递给 Dify 调用。"""
|
||
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
|
||
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
|
||
|
||
mock_dify = AsyncMock(return_value={
|
||
"is_approval_request": True,
|
||
"confidence": 0.9,
|
||
"approval_type": "设备申请",
|
||
})
|
||
|
||
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
|
||
await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "申请电脑", "employee_id": "test_emp_001"},
|
||
)
|
||
|
||
# 验证 employee_id 被传给 Dify
|
||
mock_dify.assert_called_once_with("申请电脑", "test_emp_001")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_employee_id_defaults_to_empty_string(self, client, monkeypatch):
|
||
"""未提供 employee_id 时,传给 Dify 的是空字符串。"""
|
||
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
|
||
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
|
||
|
||
mock_dify = AsyncMock(return_value={
|
||
"is_approval_request": True,
|
||
"confidence": 0.9,
|
||
"approval_type": "设备申请",
|
||
})
|
||
|
||
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
|
||
await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "申请电脑"},
|
||
)
|
||
|
||
# 验证 employee_id 默认为空字符串
|
||
mock_dify.assert_called_once_with("申请电脑", "")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_response_has_all_required_fields(self, client):
|
||
"""验证响应包含 ApprovalDetectIntentResponse 的所有必需字段。"""
|
||
response = await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "你好"},
|
||
)
|
||
|
||
data = response.json()
|
||
# 统一响应格式验证
|
||
assert data["code"] == 0
|
||
assert data["message"] == "success"
|
||
# 业务字段验证
|
||
inner = data["data"]
|
||
# ApprovalDetectIntentResponse 必需字段
|
||
assert "is_approval_request" in inner
|
||
assert "confidence" in inner
|
||
assert "source" in inner
|
||
# approval_type 是 Optional,可以为 None 但字段必须存在
|
||
assert "approval_type" in inner
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_response_field_types(self, client):
|
||
"""验证响应字段类型正确。"""
|
||
response = await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "你好"},
|
||
)
|
||
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
inner = data["data"]
|
||
assert isinstance(inner["is_approval_request"], bool)
|
||
assert isinstance(inner["confidence"], (int, float))
|
||
assert isinstance(inner["source"], str)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dify_not_called_when_keyword_misses(self, client, monkeypatch):
|
||
"""关键词未命中时,Dify 不被调用。"""
|
||
monkeypatch.setattr(settings, "approval_dify_base_url", "http://test-dify")
|
||
monkeypatch.setattr(settings, "approval_dify_api_key", "test-api-key")
|
||
|
||
mock_dify = AsyncMock(return_value={
|
||
"is_approval_request": True,
|
||
"confidence": 0.9,
|
||
"approval_type": "设备申请",
|
||
})
|
||
|
||
with patch("app.api.approval._call_dify_approval_intent", mock_dify):
|
||
response = await client.post(
|
||
"/approval/detect-intent",
|
||
json={"text": "今天天气真好"},
|
||
)
|
||
|
||
# Dify 不应被调用
|
||
mock_dify.assert_not_called()
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
assert data["data"]["source"] == "keyword_prefilter"
|
||
|
||
|
||
# =============================================================================
|
||
# 集成测试:/approval/keywords 端点
|
||
# =============================================================================
|
||
# Round 2 新增:验证 /approval/keywords 端点使用 success_response() 包装
|
||
# =============================================================================
|
||
|
||
class TestApprovalKeywordsEndpoint:
|
||
"""测试 /approval/keywords API 端点。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_keywords_response_format(self, client):
|
||
"""验证 /approval/keywords 响应使用统一格式 {code, data, message}。"""
|
||
response = await client.get("/approval/keywords")
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
# 统一响应格式验证
|
||
assert data["code"] == 0
|
||
assert data["message"] == "success"
|
||
# data 应为列表
|
||
assert isinstance(data["data"], list)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_keywords_empty_when_no_templates(self, client):
|
||
"""无审批模板配置时,返回空列表。"""
|
||
# 测试环境未设置 APPROVAL_TEMPLATE_RESOURCE / APPROVAL_TEMPLATE_DEVICE
|
||
# APPROVAL_TEMPLATES 为空字典
|
||
response = await client.get("/approval/keywords")
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
assert data["data"] == []
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_keywords_with_mocked_templates(self, client):
|
||
"""模拟有审批模板时,返回关键词列表。"""
|
||
mock_templates = {
|
||
"tpl_resource_001": {
|
||
"id": "tpl_resource_001",
|
||
"name": "资源申请",
|
||
"type": "jump",
|
||
"keywords": ["申请资源", "要资源"],
|
||
},
|
||
"tpl_device_001": {
|
||
"id": "tpl_device_001",
|
||
"name": "设备申请",
|
||
"type": "api",
|
||
"keywords": ["申请设备", "要设备"],
|
||
},
|
||
}
|
||
|
||
with patch("app.api.approval.APPROVAL_TEMPLATES", mock_templates):
|
||
response = await client.get("/approval/keywords")
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
assert data["message"] == "success"
|
||
|
||
keywords = data["data"]
|
||
assert len(keywords) == 4 # 2 模板 × 2 关键词
|
||
|
||
# 验证每个关键词条目的结构
|
||
for item in keywords:
|
||
assert "keyword" in item
|
||
assert "template_id" in item
|
||
assert "template_name" in item
|
||
assert "type" in item
|
||
|
||
# 验证包含预期的关键词
|
||
keyword_values = [item["keyword"] for item in keywords]
|
||
assert "申请资源" in keyword_values
|
||
assert "申请设备" in keyword_values
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_keywords_data_is_list_type(self, client):
|
||
"""验证 data 字段始终为列表类型(即使为空)。"""
|
||
response = await client.get("/approval/keywords")
|
||
|
||
data = response.json()
|
||
assert isinstance(data["data"], list)
|
||
|
||
|
||
# =============================================================================
|
||
# 集成测试:/approval/jump 端点
|
||
# =============================================================================
|
||
# Round 2 新增:验证 /approval/jump 端点使用 success_response() 包装
|
||
# =============================================================================
|
||
|
||
class TestApprovalJumpEndpoint:
|
||
"""测试 /approval/jump API 端点。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_jump_template_not_found(self, client):
|
||
"""模板不存在时返回 404。"""
|
||
response = await client.post(
|
||
"/approval/jump",
|
||
json={"template_id": "non_existent_template"},
|
||
)
|
||
|
||
assert response.status_code == 404
|
||
assert "模板不存在" in response.json()["detail"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_jump_type_mismatch(self, client):
|
||
"""模板类型不支持跳转(type != 'jump')时返回 400。"""
|
||
mock_templates = {
|
||
"tpl_api_001": {
|
||
"id": "tpl_api_001",
|
||
"name": "API审批",
|
||
"type": "api", # 非 jump 类型
|
||
"keywords": ["测试"],
|
||
},
|
||
}
|
||
|
||
with patch("app.api.approval.APPROVAL_TEMPLATES", mock_templates):
|
||
response = await client.post(
|
||
"/approval/jump",
|
||
json={"template_id": "tpl_api_001"},
|
||
)
|
||
|
||
assert response.status_code == 400
|
||
assert "不支持跳转方式" in response.json()["detail"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_jump_success_response_format(self, client):
|
||
"""跳转成功时返回统一格式 {code: 0, data: {url, template_name}, message}。"""
|
||
mock_templates = {
|
||
"tpl_jump_001": {
|
||
"id": "tpl_jump_001",
|
||
"name": "资源申请",
|
||
"type": "jump",
|
||
"keywords": ["申请资源"],
|
||
},
|
||
}
|
||
|
||
with patch("app.api.approval.APPROVAL_TEMPLATES", mock_templates):
|
||
response = await client.post(
|
||
"/approval/jump",
|
||
json={"template_id": "tpl_jump_001"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
# 统一响应格式验证
|
||
assert data["code"] == 0
|
||
assert data["message"] == "success"
|
||
# 业务字段验证
|
||
inner = data["data"]
|
||
assert "url" in inner
|
||
assert "template_name" in inner
|
||
assert inner["template_name"] == "资源申请"
|
||
# URL 应包含 template_id
|
||
assert "tpl_jump_001" in inner["url"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_jump_url_contains_template_id(self, client):
|
||
"""跳转 URL 中包含 template_id。"""
|
||
mock_templates = {
|
||
"tpl_jump_002": {
|
||
"id": "tpl_jump_002",
|
||
"name": "设备申请",
|
||
"type": "jump",
|
||
"keywords": ["申请设备"],
|
||
},
|
||
}
|
||
|
||
with patch("app.api.approval.APPROVAL_TEMPLATES", mock_templates):
|
||
response = await client.post(
|
||
"/approval/jump",
|
||
json={"template_id": "tpl_jump_002"},
|
||
)
|
||
|
||
data = response.json()
|
||
inner = data["data"]
|
||
assert "tpl_jump_002" in inner["url"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_jump_with_employee_id(self, client):
|
||
"""携带 employee_id 时跳转正常返回(employee_id 可选字段)。"""
|
||
mock_templates = {
|
||
"tpl_jump_003": {
|
||
"id": "tpl_jump_003",
|
||
"name": "资源申请",
|
||
"type": "jump",
|
||
"keywords": ["申请资源"],
|
||
},
|
||
}
|
||
|
||
with patch("app.api.approval.APPROVAL_TEMPLATES", mock_templates):
|
||
response = await client.post(
|
||
"/approval/jump",
|
||
json={
|
||
"template_id": "tpl_jump_003",
|
||
"employee_id": "test_emp_001",
|
||
},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["code"] == 0
|
||
assert data["data"]["template_name"] == "资源申请"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_jump_missing_template_id(self, client):
|
||
"""缺少 template_id 时返回 422(Pydantic 校验失败)。"""
|
||
response = await client.post(
|
||
"/approval/jump",
|
||
json={},
|
||
)
|
||
|
||
assert response.status_code == 422
|