mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-09-25 21:57:49 +00:00
* 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: 更新后端依赖文件
312 lines
13 KiB
Python
312 lines
13 KiB
Python
from collections.abc import Awaitable, Callable
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
|
|
|
import pytest
|
|
|
|
from config.env import AppConfig
|
|
from plugins.core.runtime.application import (
|
|
get_plugin_application_runtime as get_runtime_plugin_application_runtime,
|
|
)
|
|
from server import (
|
|
_initialize_application_runtime,
|
|
_shutdown_application_runtime,
|
|
_stop_background_tasks,
|
|
create_app,
|
|
get_plugin_application_runtime,
|
|
lifespan,
|
|
)
|
|
|
|
|
|
def test_server_reuses_plugin_application_runtime_getter() -> None:
|
|
"""校验 server 入口不直接装配插件管理适配器。"""
|
|
assert get_plugin_application_runtime is get_runtime_plugin_application_runtime
|
|
|
|
|
|
def test_create_app_registers_builtin_routes_and_binds_plugin_runtime() -> None:
|
|
"""校验 create_app 保持内置路由注册,并绑定插件应用运行时。"""
|
|
fake_plugin_runtime = MagicMock()
|
|
|
|
with (
|
|
patch('server.APIDocsUtil.setup_docs_static_resources'),
|
|
patch('server.APIDocsUtil.custom_api_docs_router'),
|
|
patch('server.handle_sub_applications'),
|
|
patch('server.handle_middleware'),
|
|
patch('server.handle_exception'),
|
|
patch('server.auto_register_routers') as auto_register_routers,
|
|
patch('server.get_plugin_application_runtime', return_value=fake_plugin_runtime),
|
|
):
|
|
app = create_app()
|
|
|
|
auto_register_routers.assert_called_once_with(app)
|
|
fake_plugin_runtime.bind_app.assert_called_once_with(app)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialize_application_runtime_delegates_plugin_steps() -> None:
|
|
"""校验应用启动流程保留原系统初始化,并只委托插件专属步骤。"""
|
|
fake_app = MagicMock()
|
|
fake_plugin_runtime = MagicMock()
|
|
fake_plugin_runtime.startup = AsyncMock()
|
|
|
|
with (
|
|
patch('server.get_plugin_application_runtime', return_value=fake_plugin_runtime),
|
|
patch('server.init_create_table', new_callable=AsyncMock) as init_create_table,
|
|
patch('server.RedisUtil.check_redis_connection', new_callable=AsyncMock) as check_redis_connection,
|
|
patch('server.RedisUtil.init_sys_dict', new_callable=AsyncMock) as init_sys_dict,
|
|
patch('server.RedisUtil.init_sys_config', new_callable=AsyncMock) as init_sys_config,
|
|
patch('server._start_background_tasks', new_callable=AsyncMock) as start_background_tasks,
|
|
):
|
|
await _initialize_application_runtime(fake_app, application_leader=True)
|
|
fake_plugin_runtime.prepare_metadata.assert_called_once_with(fake_app)
|
|
init_create_table.assert_awaited_once_with(
|
|
stage='platform',
|
|
log_success_enabled=True,
|
|
)
|
|
startup_call = fake_plugin_runtime.startup.await_args
|
|
assert startup_call.args == (fake_app,)
|
|
create_plugin_entity_tables = startup_call.kwargs['create_tables']
|
|
await create_plugin_entity_tables()
|
|
assert init_create_table.await_args_list[-1].kwargs == {
|
|
'stage': 'plugin_entities',
|
|
'log_success_enabled': True,
|
|
}
|
|
check_redis_connection.assert_awaited_once_with(
|
|
fake_app.state.redis,
|
|
log_enabled=True,
|
|
log_error_enabled=True,
|
|
)
|
|
init_sys_dict.assert_awaited_once_with(fake_app.state.redis)
|
|
init_sys_config.assert_awaited_once_with(fake_app.state.redis)
|
|
start_background_tasks.assert_awaited_once_with(fake_app)
|
|
assert fake_app.state.plugin_application_runtime_started is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_leader_plugin_writer_still_runs_global_plugin_sync() -> None:
|
|
"""校验Application非leader仍可成为插件writer并输出插件全局写入阶段。"""
|
|
fake_app = MagicMock()
|
|
fake_plugin_runtime = MagicMock()
|
|
|
|
async def run_as_plugin_writer(
|
|
_app: object,
|
|
*,
|
|
create_tables: Callable[[], Awaitable[None]],
|
|
) -> None:
|
|
"""模拟当前非leader worker获得插件生命周期锁。"""
|
|
await create_tables()
|
|
|
|
fake_plugin_runtime.startup = AsyncMock(side_effect=run_as_plugin_writer)
|
|
|
|
with (
|
|
patch('server.get_plugin_application_runtime', return_value=fake_plugin_runtime),
|
|
patch('server.init_create_table', new_callable=AsyncMock) as init_create_table,
|
|
patch('server.RedisUtil.check_redis_connection', new_callable=AsyncMock) as check_redis_connection,
|
|
patch('server.RedisUtil.init_sys_dict', new_callable=AsyncMock),
|
|
patch('server.RedisUtil.init_sys_config', new_callable=AsyncMock),
|
|
patch('server._start_background_tasks', new_callable=AsyncMock),
|
|
):
|
|
await _initialize_application_runtime(fake_app, application_leader=False)
|
|
|
|
assert init_create_table.await_args_list == [
|
|
call(stage='platform', log_success_enabled=False),
|
|
call(stage='plugin_entities', log_success_enabled=True),
|
|
]
|
|
check_redis_connection.assert_awaited_once_with(
|
|
fake_app.state.redis,
|
|
log_enabled=False,
|
|
log_error_enabled=True,
|
|
)
|
|
fake_plugin_runtime.startup.assert_awaited_once()
|
|
assert fake_app.state.plugin_application_runtime_started is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_only_application_leader_outputs_banner_and_addresses() -> None:
|
|
"""校验仅Application leader输出横幅、成功摘要和访问地址。"""
|
|
app = SimpleNamespace(state=SimpleNamespace())
|
|
redis = MagicMock()
|
|
fake_logger = MagicMock()
|
|
fake_logger.complete = AsyncMock()
|
|
|
|
with (
|
|
patch('server.RedisUtil.create_redis_pool', new=AsyncMock(return_value=redis)),
|
|
patch('server.SchedulerUtil.get_application_lock_owner_token', return_value='owner-1'),
|
|
patch('server.StartupUtil.acquire_application_leader', new=AsyncMock(return_value=True)),
|
|
patch('server.SchedulerUtil.start_application_lock_renewal') as start_renewal,
|
|
patch('server.SchedulerUtil.is_application_leader', return_value=True),
|
|
patch('server.TransportKeyProvider.validate_runtime_configuration'),
|
|
patch('server._initialize_application_runtime', new_callable=AsyncMock) as initialize_runtime,
|
|
patch('server._shutdown_application_runtime', new_callable=AsyncMock) as shutdown_runtime,
|
|
patch('server.asyncio.sleep', new_callable=AsyncMock),
|
|
patch('server.IPUtil.get_local_ip', return_value='127.0.0.1'),
|
|
patch('server.IPUtil.get_network_ips', return_value=['192.0.2.1']),
|
|
patch('server.worship') as worship,
|
|
patch('server.logger', fake_logger),
|
|
):
|
|
async with lifespan(app):
|
|
pass
|
|
|
|
start_renewal.assert_called_once_with(redis)
|
|
initialize_runtime.assert_awaited_once_with(app, application_leader=True)
|
|
worship.assert_called_once_with()
|
|
fake_logger.bind.return_value.info.assert_has_calls(
|
|
[
|
|
call(f'⏰️ {AppConfig.app_name}开始启动'),
|
|
call(f'🚀 {AppConfig.app_name}启动成功'),
|
|
]
|
|
)
|
|
assert any(
|
|
logged_call.args[0].startswith('💻 应用地址:')
|
|
for logged_call in fake_logger.opt.return_value.info.call_args_list
|
|
)
|
|
fake_logger.complete.assert_awaited_once_with()
|
|
shutdown_runtime.assert_awaited_once_with(app)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_non_leader_runs_local_initialization_without_display_logs() -> None:
|
|
"""校验非leader执行本地初始化,但不输出leader专属展示日志。"""
|
|
app = SimpleNamespace(state=SimpleNamespace())
|
|
redis = MagicMock()
|
|
fake_logger = MagicMock()
|
|
fake_logger.complete = AsyncMock()
|
|
|
|
with (
|
|
patch('server.RedisUtil.create_redis_pool', new=AsyncMock(return_value=redis)),
|
|
patch('server.SchedulerUtil.get_application_lock_owner_token', return_value='owner-2'),
|
|
patch('server.StartupUtil.acquire_application_leader', new=AsyncMock(return_value=False)),
|
|
patch('server.SchedulerUtil.start_application_lock_renewal') as start_renewal,
|
|
patch('server.SchedulerUtil.is_application_leader') as is_application_leader,
|
|
patch('server.TransportKeyProvider.validate_runtime_configuration'),
|
|
patch('server._initialize_application_runtime', new_callable=AsyncMock) as initialize_runtime,
|
|
patch('server._shutdown_application_runtime', new_callable=AsyncMock) as shutdown_runtime,
|
|
patch('server.worship') as worship,
|
|
patch('server.logger', fake_logger),
|
|
):
|
|
async with lifespan(app):
|
|
pass
|
|
|
|
start_renewal.assert_not_called()
|
|
is_application_leader.assert_not_called()
|
|
initialize_runtime.assert_awaited_once_with(app, application_leader=False)
|
|
worship.assert_not_called()
|
|
fake_logger.bind.return_value.info.assert_not_called()
|
|
fake_logger.opt.assert_not_called()
|
|
fake_logger.complete.assert_awaited_once_with()
|
|
shutdown_runtime.assert_awaited_once_with(app)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lifespan_non_leader_initialization_error_propagates_and_still_cleans_up() -> None:
|
|
"""校验非leader初始化异常不被门禁吞掉,并继续执行finally清理。"""
|
|
app = SimpleNamespace(state=SimpleNamespace())
|
|
fake_logger = MagicMock()
|
|
fake_logger.complete = AsyncMock()
|
|
|
|
with (
|
|
patch('server.RedisUtil.create_redis_pool', new=AsyncMock(return_value=MagicMock())),
|
|
patch('server.SchedulerUtil.get_application_lock_owner_token', return_value='owner-3'),
|
|
patch('server.StartupUtil.acquire_application_leader', new=AsyncMock(return_value=False)),
|
|
patch('server.TransportKeyProvider.validate_runtime_configuration'),
|
|
patch(
|
|
'server._initialize_application_runtime',
|
|
new=AsyncMock(side_effect=RuntimeError('non-leader initialization failed')),
|
|
) as initialize_runtime,
|
|
patch('server._shutdown_application_runtime', new_callable=AsyncMock) as shutdown_runtime,
|
|
patch('server.logger', fake_logger),
|
|
pytest.raises(RuntimeError, match='non-leader initialization failed'),
|
|
):
|
|
async with lifespan(app):
|
|
pass
|
|
|
|
initialize_runtime.assert_awaited_once_with(app, application_leader=False)
|
|
fake_logger.bind.return_value.info.assert_not_called()
|
|
fake_logger.complete.assert_not_awaited()
|
|
shutdown_runtime.assert_awaited_once_with(app)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_application_runtime_preserves_cleanup_order() -> None:
|
|
"""校验插件关闭先执行,随后按Scheduler、Redis、数据库顺序释放资源。"""
|
|
events: list[str] = []
|
|
app = SimpleNamespace(
|
|
state=SimpleNamespace(
|
|
plugin_application_runtime_started=True,
|
|
redis=object(),
|
|
)
|
|
)
|
|
plugin_runtime = MagicMock()
|
|
|
|
async def record_plugin_shutdown(_app: object) -> None:
|
|
events.append('plugin')
|
|
|
|
async def record_scheduler_shutdown() -> None:
|
|
events.append('scheduler')
|
|
|
|
async def record_redis_shutdown(_app: object) -> None:
|
|
events.append('redis')
|
|
|
|
async def record_database_shutdown() -> None:
|
|
events.append('database')
|
|
|
|
async def record_log_complete() -> None:
|
|
events.append('logs')
|
|
|
|
plugin_runtime.shutdown = AsyncMock(side_effect=record_plugin_shutdown)
|
|
with (
|
|
patch('server.get_plugin_application_runtime', return_value=plugin_runtime),
|
|
patch(
|
|
'server.SchedulerUtil.close_system_scheduler',
|
|
new=AsyncMock(side_effect=record_scheduler_shutdown),
|
|
),
|
|
patch(
|
|
'server.RedisUtil.close_redis_pool',
|
|
new=AsyncMock(side_effect=record_redis_shutdown),
|
|
),
|
|
patch('server.close_async_engine', new=AsyncMock(side_effect=record_database_shutdown)),
|
|
patch('server.logger.complete', new=AsyncMock(side_effect=record_log_complete)),
|
|
):
|
|
await _shutdown_application_runtime(app)
|
|
|
|
assert events == ['plugin', 'scheduler', 'redis', 'database', 'logs']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_application_runtime_releases_resources_when_plugin_hook_fails() -> None:
|
|
"""校验插件关闭钩子失败时仍执行基础设施清理。"""
|
|
app = SimpleNamespace(state=SimpleNamespace(plugin_application_runtime_started=True))
|
|
plugin_runtime = MagicMock()
|
|
plugin_runtime.shutdown = AsyncMock(side_effect=RuntimeError('shutdown hook failed'))
|
|
|
|
with (
|
|
patch('server.get_plugin_application_runtime', return_value=plugin_runtime),
|
|
patch('server._stop_background_tasks', new_callable=AsyncMock) as stop_background_tasks,
|
|
patch('server.logger.complete', new_callable=AsyncMock) as complete_logs,
|
|
pytest.raises(RuntimeError, match='shutdown hook failed'),
|
|
):
|
|
await _shutdown_application_runtime(app)
|
|
|
|
stop_background_tasks.assert_awaited_once_with(app)
|
|
complete_logs.assert_awaited_once_with()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_background_tasks_closes_redis_and_database_when_scheduler_close_fails() -> None:
|
|
"""校验Scheduler释放异常不会跳过Redis和数据库清理。"""
|
|
app = SimpleNamespace(state=SimpleNamespace(redis=object()))
|
|
|
|
with (
|
|
patch(
|
|
'server.SchedulerUtil.close_system_scheduler',
|
|
new=AsyncMock(side_effect=RuntimeError('scheduler close failed')),
|
|
),
|
|
patch('server.RedisUtil.close_redis_pool', new_callable=AsyncMock) as close_redis_pool,
|
|
patch('server.close_async_engine', new_callable=AsyncMock) as close_async_engine,
|
|
pytest.raises(RuntimeError, match='scheduler close failed'),
|
|
):
|
|
await _stop_background_tasks(app)
|
|
|
|
close_redis_pool.assert_awaited_once_with(app)
|
|
close_async_engine.assert_awaited_once_with()
|