# ============================================================================= # 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