mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-30 04:27:40 +08:00
311 lines
13 KiB
Python
311 lines
13 KiB
Python
"""
|
||
LLM 提供商通用管理契约(provider_manage)守护测试
|
||
|
||
验证通用模式的三条核心性质:
|
||
1. 动作词汇表由 schemas 契约层统一定义,未支持动作返回统一错误结构
|
||
2. 动作标识兼容枚举与原始字符串,统一返回 {"success", "message", "data"}
|
||
3. 端点层零提供商特色:ManageRequest 原样透传,默认值填充/校验/脱敏封闭在 Manager 内
|
||
"""
|
||
import asyncio
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
import pytest
|
||
|
||
from app import schemas
|
||
from app.agent.llm.helper import LLMHelper
|
||
from app.agent.llm.provider import LLMProviderManager
|
||
from app.runtime.config import settings
|
||
from app.schemas.types import LlmProviderAction
|
||
|
||
|
||
@pytest.fixture
|
||
def manager():
|
||
return LLMProviderManager()
|
||
|
||
|
||
def test_provider_manage_rejects_unknown_action(manager):
|
||
"""动作词汇表之外的请求返回统一错误结构。"""
|
||
result = asyncio.run(manager.provider_manage("openai", "not_an_action"))
|
||
assert result["success"] is False
|
||
assert "不支持" in result["message"]
|
||
assert "data" in result
|
||
|
||
|
||
def test_provider_manage_accepts_enum_and_string_action(manager, monkeypatch):
|
||
"""动作标识兼容枚举对象与原始字符串,两种形式等价。"""
|
||
clear_mock = AsyncMock()
|
||
monkeypatch.setattr(manager, "clear_auth", clear_mock)
|
||
for action in (LlmProviderAction.DISCONNECT, "disconnect"):
|
||
result = asyncio.run(manager.provider_manage("openai", action))
|
||
assert result["success"] is True
|
||
assert result["data"] is None
|
||
assert clear_mock.await_count == 2
|
||
|
||
|
||
def test_provider_manage_test_requires_ai_agent_enabled(manager):
|
||
"""智能助手未启用时测试动作返回提示。"""
|
||
with patch.object(settings, "AI_AGENT_ENABLE", False):
|
||
result = asyncio.run(manager.provider_manage("deepseek", "test"))
|
||
assert result["success"] is False
|
||
assert result["message"] == "请先启用智能助手"
|
||
|
||
|
||
def test_provider_manage_test_requires_model(manager):
|
||
"""未配置模型时测试动作返回提示。"""
|
||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||
settings, "LLM_API_KEY", "sk-test"
|
||
), patch.object(settings, "LLM_MODEL", ""):
|
||
result = asyncio.run(manager.provider_manage("deepseek", "test"))
|
||
assert result["success"] is False
|
||
assert result["message"] == "请先配置 LLM 模型"
|
||
|
||
|
||
def test_provider_manage_test_requires_api_key(manager, monkeypatch):
|
||
"""无 OAuth 授权方式且无已保存凭据的提供商必须配置 API Key。"""
|
||
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
|
||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||
settings, "LLM_API_KEY", None
|
||
), patch.object(settings, "LLM_MODEL", "deepseek-chat"):
|
||
result = asyncio.run(manager.provider_manage("deepseek", "test"))
|
||
assert result["success"] is False
|
||
assert result["message"] == "请先配置 LLM API Key"
|
||
assert result["data"]["model"] == "deepseek-chat"
|
||
|
||
|
||
def test_provider_manage_test_exempts_oauth_providers_from_api_key(manager, monkeypatch):
|
||
"""支持 OAuth 授权的提供商无需 API Key,端点与 Manager 均不硬编码提供商名。"""
|
||
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
|
||
monkeypatch.setattr(
|
||
manager, "get_provider", lambda provider_id: SimpleNamespace(oauth_methods=("browser_oauth",))
|
||
)
|
||
test_mock = AsyncMock(return_value={"provider": "chatgpt", "model": "gpt-4o", "reply_preview": "OK"})
|
||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||
settings, "LLM_API_KEY", None
|
||
), patch.object(LLMHelper, "test_current_settings", test_mock):
|
||
result = asyncio.run(
|
||
manager.provider_manage("chatgpt", "test", model="gpt-4o")
|
||
)
|
||
assert result["success"] is True
|
||
test_mock.assert_awaited_once()
|
||
|
||
|
||
def test_provider_manage_test_returns_reply_preview(manager, monkeypatch):
|
||
"""测试成功时返回模型响应预览,显式参数优先于已保存配置。"""
|
||
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
|
||
test_mock = AsyncMock(
|
||
return_value={"provider": "openai", "model": "gpt-4.1-mini", "duration_ms": 123, "reply_preview": "OK"}
|
||
)
|
||
with patch.object(settings, "AI_AGENT_ENABLE", False), patch.object(
|
||
LLMHelper, "test_current_settings", test_mock
|
||
):
|
||
result = asyncio.run(
|
||
manager.provider_manage(
|
||
"openai",
|
||
"test",
|
||
enabled=True,
|
||
model="gpt-4.1-mini",
|
||
thinking_level="high",
|
||
api_key="sk-live",
|
||
base_url="https://example.com/v1",
|
||
use_proxy=False,
|
||
)
|
||
)
|
||
test_mock.assert_awaited_once_with(
|
||
provider="openai",
|
||
model="gpt-4.1-mini",
|
||
thinking_level="high",
|
||
api_key="sk-live",
|
||
base_url="https://example.com/v1",
|
||
base_url_preset=None,
|
||
user_agent=None,
|
||
use_proxy=False,
|
||
api_protocol=None,
|
||
web_search_mode=None,
|
||
)
|
||
assert result["success"] is True
|
||
assert result["data"]["reply_preview"] == "OK"
|
||
|
||
|
||
def test_provider_manage_test_rejects_empty_reply(manager, monkeypatch):
|
||
"""模型响应为空时返回失败但保留结果详情。"""
|
||
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
|
||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||
LLMHelper,
|
||
"test_current_settings",
|
||
AsyncMock(return_value={"provider": "deepseek", "model": "deepseek-chat", "duration_ms": 12}),
|
||
):
|
||
result = asyncio.run(
|
||
manager.provider_manage("deepseek", "test", model="deepseek-chat", api_key="sk-test")
|
||
)
|
||
assert result["success"] is False
|
||
assert result["message"] == "模型响应为空"
|
||
assert result["data"]["duration_ms"] == 12
|
||
|
||
|
||
def test_provider_manage_test_maps_timeout_error(manager, monkeypatch):
|
||
"""调用超时返回统一的超时提示。"""
|
||
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
|
||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||
LLMHelper,
|
||
"test_current_settings",
|
||
AsyncMock(side_effect=TimeoutError("request timed out")),
|
||
):
|
||
result = asyncio.run(
|
||
manager.provider_manage("deepseek", "test", model="deepseek-chat", api_key="sk-test")
|
||
)
|
||
assert result["success"] is False
|
||
assert result["message"] == "LLM 调用超时"
|
||
|
||
|
||
def test_provider_manage_test_sanitizes_error_message(manager, monkeypatch):
|
||
"""错误信息中的密钥与授权头必须脱敏。"""
|
||
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
|
||
raw_error = (
|
||
"request failed api_key=sk-secret "
|
||
"Authorization: Bearer sk-secret "
|
||
"base error sk-secret"
|
||
)
|
||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||
LLMHelper, "test_current_settings", AsyncMock(side_effect=RuntimeError(raw_error))
|
||
):
|
||
result = asyncio.run(
|
||
manager.provider_manage("deepseek", "test", model="deepseek-chat", api_key="sk-secret")
|
||
)
|
||
assert result["success"] is False
|
||
assert "sk-secret" not in result["message"]
|
||
assert "Authorization: Bearer" not in result["message"]
|
||
assert "***" in result["message"]
|
||
|
||
|
||
def test_provider_manage_test_maps_internal_error_to_base_url_hint(manager, monkeypatch):
|
||
"""SDK 内部响应解析错误应改写为可定位的基础地址提示。"""
|
||
monkeypatch.setattr(manager, "get_saved_auth", lambda provider_id: None)
|
||
with patch.object(settings, "AI_AGENT_ENABLE", True), patch.object(
|
||
LLMHelper,
|
||
"test_current_settings",
|
||
AsyncMock(side_effect=RuntimeError("'str' object has no attribute 'model_dump'")),
|
||
):
|
||
result = asyncio.run(
|
||
manager.provider_manage("openai", "test", model="gpt-4o-mini", api_key="sk-test")
|
||
)
|
||
assert result["success"] is False
|
||
assert "基础地址" in result["message"]
|
||
assert "API Base URL" in result["message"]
|
||
assert "model_dump" not in result["message"]
|
||
|
||
|
||
def test_provider_manage_list_models_sanitizes_base_url_hint(manager):
|
||
"""模型列表查询遇到 SDK 内部错误时同样给出基础地址提示。"""
|
||
with patch.object(
|
||
LLMHelper,
|
||
"get_models",
|
||
AsyncMock(side_effect=AttributeError("'str' object has no attribute '_set_private_attributes'")),
|
||
):
|
||
result = asyncio.run(
|
||
manager.provider_manage("openai", "list_models", api_key="sk-test", base_url="https://example.com")
|
||
)
|
||
assert result["success"] is False
|
||
assert "基础地址" in result["message"]
|
||
assert "API Base URL" in result["message"]
|
||
assert "_set_private_attributes" not in result["message"]
|
||
|
||
|
||
def test_provider_manage_list_models_returns_catalog_with_auth_status(manager):
|
||
"""模型目录查询成功时附带授权状态摘要。"""
|
||
models = [{"id": "gpt-4o"}]
|
||
with patch.object(LLMHelper, "get_models", AsyncMock(return_value=models)), patch.object(
|
||
manager, "get_auth_status", lambda provider_id: {"connected": False}
|
||
):
|
||
result = asyncio.run(manager.provider_manage("openai", "list_models"))
|
||
assert result["success"] is True
|
||
assert result["data"]["provider"] == "openai"
|
||
assert result["data"]["models"] == models
|
||
assert result["data"]["auth_status"] == {"connected": False}
|
||
|
||
|
||
def test_llm_manage_endpoint_passes_through_manage_request(monkeypatch):
|
||
"""端点仅透传 ManageRequest,并按具名回调路由注入 OAuth 回跳地址。"""
|
||
from app.api.endpoints import llm as llm_endpoint
|
||
|
||
captured = {}
|
||
|
||
async def fake_manage(self, provider, action, **params):
|
||
captured["provider"] = provider
|
||
captured["action"] = action
|
||
captured["params"] = params
|
||
return {"success": True, "message": "", "data": {"ok": True}}
|
||
|
||
monkeypatch.setattr(LLMProviderManager, "provider_manage", fake_manage)
|
||
request = SimpleNamespace(
|
||
url_for=lambda name, **kwargs: f"https://host/api/v1/llm/provider-auth/callback/{kwargs['provider_id']}"
|
||
)
|
||
payload = schemas.ManageRequest(target="chatgpt", action="start_auth", params={"method": "browser_oauth"})
|
||
|
||
resp = asyncio.run(llm_endpoint.manage_provider(request, payload, _="token"))
|
||
|
||
assert resp.success is True
|
||
assert resp.data == {"ok": True}
|
||
assert captured["provider"] == "chatgpt"
|
||
assert captured["action"] == "start_auth"
|
||
assert captured["params"]["method"] == "browser_oauth"
|
||
assert captured["params"]["callback_url"].endswith("/callback/chatgpt")
|
||
|
||
|
||
def test_llm_manage_endpoint_accepts_empty_target(monkeypatch):
|
||
"""目录类查询动作 target 可为空,不得因 url_for 空路径参数报 500。
|
||
|
||
回归守护:前端加载提供商目录时 target 为空字符串,
|
||
回调地址构造必须跳过空 target。
|
||
"""
|
||
from app.api.endpoints import llm as llm_endpoint
|
||
|
||
captured = {}
|
||
|
||
async def fake_manage(self, provider, action, **params):
|
||
captured["provider"] = provider
|
||
captured["params"] = params
|
||
return {"success": True, "message": "", "data": []}
|
||
|
||
def fail_url_for(name, **kwargs):
|
||
raise AssertionError("空 target 不应构造回调地址")
|
||
|
||
monkeypatch.setattr(LLMProviderManager, "provider_manage", fake_manage)
|
||
request = SimpleNamespace(url_for=fail_url_for)
|
||
payload = schemas.ManageRequest(target="", action="list_providers")
|
||
|
||
resp = asyncio.run(llm_endpoint.manage_provider(request, payload, _="token"))
|
||
|
||
assert resp.success is True
|
||
assert captured["provider"] == ""
|
||
assert "callback_url" not in captured["params"]
|
||
|
||
|
||
def test_llm_manage_endpoint_response_model_accepts_list_data():
|
||
"""目录查询动作 data 为列表,响应模型须同时覆盖列表与映射形态。
|
||
|
||
回归守护:list_providers 返回 list[dict],
|
||
若响应模型声明为 Dict[str, Any] 会在序列化校验时直接 500;
|
||
同时受响应模型守护测试约束,不得使用 Any/JsonData 弱类型。
|
||
"""
|
||
from typing import Any, Dict, List, Union
|
||
|
||
from app.api.endpoints import llm as llm_endpoint
|
||
|
||
route = next(
|
||
r for r in llm_endpoint.router.routes if getattr(r, "path", "").endswith("/manage")
|
||
)
|
||
assert route.response_model is schemas.Response[
|
||
Union[List[Dict[str, Any]], Dict[str, Any]]
|
||
]
|
||
|
||
list_resp = schemas.Response[Union[List[Dict[str, Any]], Dict[str, Any]]](
|
||
success=True, message="", data=[{"id": "openai", "name": "OpenAI 兼容"}]
|
||
)
|
||
assert list_resp.data == [{"id": "openai", "name": "OpenAI 兼容"}]
|
||
|
||
dict_resp = schemas.Response[Union[List[Dict[str, Any]], Dict[str, Any]]](
|
||
success=True, message="", data={"models": ["gpt-4o"]}
|
||
)
|
||
assert dict_resp.data == {"models": ["gpt-4o"]}
|