Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/tests/plugins/sample_plugins/test_ai_plugin.py
T
insistence 2a055ba648 feat: 新增插件系统 (#112)
* feat: 初始化插件系统

* refactor: 收口插件系统运行时重构

* perf: 优化插件系统类型提示

* fix&perf: 修复和优化插件系统

* fix: 修复gitignore规则误忽略插件文件的问题

* fix: 修复运行时插件根路径算错的问题

* fix: 加强插件发现和路由注册的防护措施

* revert: 回滚定时任务白名单

* fix: 移除未使用的应用路由注册探测

* revert: 恢复部分代码

* perf: 优化插件系统

* docs: 新增插件开发文档

* perf: 优化插件管理模块

* perf: 提升插件系统核心能力

* refactor: 重构生命周期 step runner

* fix: 修复lint错误

* test: 清理测试用例

* test: 调整测试目录名称

* fix: 修复前后端目录硬编码的问题

* fix: 修复插件系统安全性缺口

* refactor: 重新设计插件生命周期 Migration 事务与回滚

* perf: 优化插件系统边界问题

* refactor: 重构当前插件系统的依赖体系设计

* perf: 优化代码

* perf: 优化代码

* fix: 修复代码合并问题

* fix: 修复bug

* perf: 优化代码

* perf&fix: 优化代码和修复bug

* docs: 优化文档格式

* feat: 适配Vue2版本

* docs: 更新README文档

* fix: 修复ruff lint错误

* chore: 更新后端依赖文件
2026-07-28 20:35:18 +08:00

282 lines
11 KiB
Python

from pathlib import Path
import pytest
from cli.runtime.plugin.scaffold import PluginFrontendVersionResolver
from exceptions.exception import ServiceException
from plugins.ai.dao.ai_chat_dao import AiChatConfigDao
from plugins.ai.dao.ai_model_dao import AiModelDao
from plugins.ai.entity.vo.ai_chat_vo import AiChatConfigModel, AiChatRequestModel
from plugins.ai.service.ai_chat_service import AiChatService
from plugins.ai.utils.ai_util import AiUtil
from plugins.core.discovery.registry import PluginRegistry
from plugins.core.discovery.scanner import PluginScanner
from plugins.core.management.entity.vo.schemas import PluginModel
from plugins.core.validation.structure import PluginStructureChecker
BACKEND_ROOT = Path(__file__).resolve().parents[3]
PROJECT_ROOT = BACKEND_ROOT.parent
FRONTEND_ROOT = PROJECT_ROOT / 'ruoyi-fastapi-frontend'
FRONTEND_VERSION = PluginFrontendVersionResolver.resolve(FRONTEND_ROOT)
EXPECTED_AI_PERMISSIONS = {
'ai:model:list',
'ai:model:add',
'ai:model:edit',
'ai:model:remove',
'ai:model:query',
'ai:chat:list',
}
EXPECTED_AI_PYTHON_DEPENDENCIES = [
'agno==2.4.8',
'anthropic==0.78.0',
'cerebras-cloud-sdk==1.67.0',
'cohere==5.20.4',
'google-genai==1.62.0',
'groq==1.0.0',
'litellm==1.81.8',
'llama-api-client==0.6.0',
'mistralai==1.12.0',
'ollama==0.6.1',
'openai==2.17.0',
'portkey-ai==2.1.0',
]
EXPECTED_AI_VUE2_NPM_DEPENDENCIES = [
'@antv/infographic^0.2.13',
'katex>=0.16.27',
'markstream-vue2^0.0.18',
'mermaid>=11.12.2',
'shiki^3.21.0',
'stream-markdown>=0.0.14',
]
EXPECTED_AI_VUE3_NPM_DEPENDENCIES = [
'@antv/infographic^0.2.13',
'katex>=0.16.27',
'markstream-vue>=0.0.7-beta.6',
'mermaid>=11.12.2',
'shiki^3.21.0',
'stream-markdown>=0.0.14',
'stream-monaco>=0.0.17',
]
EXPECTED_AI_VUE3_NPM_DEV_DEPENDENCIES = ['vite-plugin-monaco-editor-esm==2.0.2']
EXPECTED_DEFAULT_NUM_HISTORY_RUNS = 3
def test_ai_plugin_template_can_be_discovered() -> None:
"""校验仓库内 AI 插件可以被插件扫描器读取。"""
plugin = PluginScanner(BACKEND_ROOT / 'plugins').load_manifest(BACKEND_ROOT / 'plugins' / 'ai' / 'plugin.yaml')
assert plugin.manifest.id == 'ai'
assert plugin.manifest.backend.module == 'plugins.ai'
assert plugin.manifest.backend.migrations == [
'migrations/mysql/001_init.sql',
'migrations/postgresql/001_init.sql',
]
assert plugin.manifest.backend.seeds == [
'seeds/mysql/ai_provider_type.sql',
'seeds/postgresql/ai_provider_type.sql',
]
assert plugin.manifest.frontend.menus[0].children[0].component == 'plugin/ai/model/index'
assert plugin.manifest.frontend.menus[0].children[1].component == 'plugin/ai/chat/index'
assert set(plugin.manifest.permission_codes) == EXPECTED_AI_PERMISSIONS
assert plugin.manifest.permission_name_map['ai:model:add'] == '新增模型'
assert plugin.manifest.dependencies.python == EXPECTED_AI_PYTHON_DEPENDENCIES
assert plugin.manifest.config.items == []
@pytest.mark.skipif(FRONTEND_VERSION != 'vue2', reason='当前项目不是 Vue 2 前端')
def test_ai_plugin_vue2_frontend_dependencies() -> None:
"""校验 Vue 2 项目的 AI 插件声明 Vue 2 专属前端依赖。"""
plugin = PluginScanner(BACKEND_ROOT / 'plugins').load_manifest(BACKEND_ROOT / 'plugins' / 'ai' / 'plugin.yaml')
assert plugin.manifest.dependencies.npm == EXPECTED_AI_VUE2_NPM_DEPENDENCIES
assert plugin.manifest.dependencies.npm_dev == []
@pytest.mark.skipif(FRONTEND_VERSION != 'vue3', reason='当前项目不是 Vue 3 前端')
def test_ai_plugin_vue3_frontend_dependencies() -> None:
"""校验 Vue 3 项目的 AI 插件声明 Vue 3 专属前端依赖。"""
plugin = PluginScanner(BACKEND_ROOT / 'plugins').load_manifest(BACKEND_ROOT / 'plugins' / 'ai' / 'plugin.yaml')
assert plugin.manifest.dependencies.npm == EXPECTED_AI_VUE3_NPM_DEPENDENCIES
assert plugin.manifest.dependencies.npm_dev == EXPECTED_AI_VUE3_NPM_DEV_DEPENDENCIES
def test_ai_plugin_runtime_paths_exist() -> None:
"""校验 AI 插件后端和前端运行路径存在。"""
plugin = PluginScanner(BACKEND_ROOT / 'plugins').load_manifest(BACKEND_ROOT / 'plugins' / 'ai' / 'plugin.yaml')
registry = PluginRegistry.build(
[plugin],
[
PluginModel(
pluginId='ai',
pluginName='AI 管理',
version='0.1.0',
installedVersion='0.1.0',
enabled='0',
status='installed',
)
],
)
assert registry.get_enabled_controller_dirs() == [BACKEND_ROOT / 'plugins' / 'ai' / 'controller']
assert registry.get_enabled_entity_do_dirs() == [BACKEND_ROOT / 'plugins' / 'ai' / 'entity' / 'do']
assert (BACKEND_ROOT / 'plugins' / 'ai' / 'controller').is_dir()
assert (BACKEND_ROOT / 'plugins' / 'ai' / 'entity' / 'do').is_dir()
assert (FRONTEND_ROOT / 'plugins' / 'ai' / 'views' / 'model' / 'index.vue').is_file()
assert (FRONTEND_ROOT / 'plugins' / 'ai' / 'views' / 'chat' / 'index.vue').is_file()
assert not (FRONTEND_ROOT / 'src' / 'api' / 'ai').exists()
assert not (FRONTEND_ROOT / 'src' / 'views' / 'ai').exists()
def test_ai_plugin_structure_check_passes() -> None:
"""校验 AI 插件目录满足插件结构检查。"""
plugin = PluginScanner(BACKEND_ROOT / 'plugins').load_manifest(BACKEND_ROOT / 'plugins' / 'ai' / 'plugin.yaml')
result = PluginStructureChecker(BACKEND_ROOT).check(plugin)
assert result.ok is True
assert result.failed_items == []
@pytest.mark.asyncio
async def test_ai_chat_config_detail_returns_vo_when_config_missing(monkeypatch: pytest.MonkeyPatch) -> None:
"""校验 AI 对话配置不存在时服务返回 VO 默认模型,而不是 DO 对象。"""
async def fake_get_chat_config_detail_by_user_id(*_args: object, **_kwargs: object) -> None:
"""返回测试用 AI 聊天配置。"""
monkeypatch.setattr(
AiChatConfigDao,
'get_chat_config_detail_by_user_id',
fake_get_chat_config_detail_by_user_id,
)
result = await AiChatService.ai_chat_config_detail_services(query_db=object(), user_id=1)
assert isinstance(result, AiChatConfigModel)
assert result.num_history_runs == EXPECTED_DEFAULT_NUM_HISTORY_RUNS
@pytest.mark.asyncio
async def test_ai_chat_services_rejects_missing_model(monkeypatch: pytest.MonkeyPatch) -> None:
"""校验 AI 对话在模型不存在时直接返回明确业务异常。"""
async def fake_get_ai_model_detail_by_id(*_args: object, **_kwargs: object) -> None:
"""返回测试用 AI 模型配置。"""
monkeypatch.setattr(AiModelDao, 'get_ai_model_detail_by_id', fake_get_ai_model_detail_by_id)
stream = AiChatService.chat_services(
query_db=object(),
chat_req=AiChatRequestModel(modelId=404, message='hi'),
user_id=1,
)
with pytest.raises(ServiceException) as exc_info:
await anext(stream)
assert exc_info.value.message == '模型不存在'
@pytest.mark.asyncio
async def test_ai_chat_services_rejects_disabled_model(monkeypatch: pytest.MonkeyPatch) -> None:
"""校验 AI 对话不能使用已停用模型。"""
async def fake_get_ai_model_detail_by_id(*_args: object, **_kwargs: object) -> dict[str, object]:
"""返回测试用 AI 模型配置。"""
return {
'model_id': 1,
'model_code': 'gpt-4o-mini',
'model_name': 'GPT',
'provider': 'OpenAI',
'api_key': 'encrypted',
'status': '1',
'support_reasoning': 'N',
'support_images': 'N',
}
monkeypatch.setattr(AiModelDao, 'get_ai_model_detail_by_id', fake_get_ai_model_detail_by_id)
stream = AiChatService.chat_services(
query_db=object(),
chat_req=AiChatRequestModel(modelId=1, message='hi'),
user_id=1,
)
with pytest.raises(ServiceException) as exc_info:
await anext(stream)
assert exc_info.value.message == '模型已停用'
def test_ai_model_factory_rejects_unknown_provider(monkeypatch: pytest.MonkeyPatch) -> None:
"""校验未知 AI provider 不会静默回退到 OpenAI。"""
class FakeModel:
"""
测试用模型类。
"""
def __init__(self, **kwargs: object) -> None:
"""初始化测试用 AI 模型提供者。"""
self.kwargs = kwargs
def fake_resolve_provider_class(cls: type[AiUtil], provider: str) -> type[FakeModel] | None:
"""返回测试用 AI 模型提供者类。"""
return FakeModel if provider == 'OpenAI' else None
monkeypatch.setattr(AiUtil, '_resolve_provider_class', classmethod(fake_resolve_provider_class))
with pytest.raises(ValueError, match='未知AI模型提供商'):
AiUtil.get_model_from_factory(
provider='UnknownAI',
model_code='demo',
model_name='Demo',
api_key='secret',
)
def test_ai_model_factory_validates_base_url_boundary() -> None:
"""校验 AI 模型工厂限制基础 URL 的信任边界。"""
assert AiUtil._validate_base_url('https://api.example.com/v1') == 'https://api.example.com/v1'
blocked_urls = (
'http://127.0.0.1:8000',
'http://10.0.0.1',
'http://localhost:8000',
'ftp://api.example.com',
)
for base_url in blocked_urls:
with pytest.raises(ValueError):
AiUtil._validate_base_url(base_url)
def test_ai_plugin_sql_assets_own_ai_schema_and_seed_data() -> None:
"""校验 AI 插件通过 SQL migration 和 seed 管理自身数据。"""
plugin_root = BACKEND_ROOT / 'plugins' / 'ai'
mysql_migration = (plugin_root / 'migrations' / 'mysql' / '001_init.sql').read_text(encoding='utf-8')
postgres_migration = (plugin_root / 'migrations' / 'postgresql' / '001_init.sql').read_text(encoding='utf-8')
mysql_seed = (plugin_root / 'seeds' / 'mysql' / 'ai_provider_type.sql').read_text(encoding='utf-8')
postgres_seed = (plugin_root / 'seeds' / 'postgresql' / 'ai_provider_type.sql').read_text(encoding='utf-8')
for sql_content in (mysql_migration, postgres_migration):
assert '-- AI模型表' in sql_content
assert '-- AI对话配置表' in sql_content
assert 'create table ai_models' in sql_content
assert 'create table ai_chat_config' in sql_content
for sql_content in (mysql_seed, postgres_seed):
assert '-- 初始化-字典类型表数据' in sql_content
assert '-- 初始化-字典数据表数据' in sql_content
assert 'ai_provider_type' in sql_content
assert 'OpenAI' in sql_content
assert 'sysdate()' in mysql_seed
assert 'current_timestamp' not in mysql_seed
assert 'current_timestamp' in postgres_seed
assert 'sysdate()' not in postgres_seed
assert 'auto_increment' in mysql_migration
assert 'engine=innodb' in mysql_migration
assert 'bigserial' not in mysql_migration
assert 'comment on column' not in mysql_migration
assert 'bigserial' in postgres_migration
assert 'comment on column' in postgres_migration
assert 'auto_increment' not in postgres_migration
assert 'engine=innodb' not in postgres_migration