Files
MoviePilot/tests/test_plugin_agent_pets.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

449 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Agent 助手形象扩展契约的后端测试:投影规整、接口鉴权、默认值设置与用户私有数据上限。
"""
import asyncio
import inspect
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.adapters.web.security.access import verify_token
from app.api.endpoints import plugin as plugin_endpoint
from app.api.endpoints import user as user_endpoint
from app.application.security.userconfig import (
AGENT_PET_STATE_MAX_BYTES,
UserConfigurationService,
UserConfigurationValueTooLargeError,
)
from app.application.settings.contract import ALL_SETTING_SPECS
from app.runtime.config import Settings, settings
from app.runtime.extensions.plugin.manager import PluginManager
from app.runtime.extensions.plugin.projection import PluginProjection
from app.schemas.plugin import PluginAgentPet
class _Plugin(SimpleNamespace):
"""
提供可配置 hook 的最小运行态插件替身。
"""
def __init__(self, enabled=True, render=("vue", "dist/assets"), **attrs):
"""
保存启用状态、渲染模式与 hook 实现。
"""
attrs.setdefault("plugin_name", "形象插件")
super().__init__(**attrs)
self._enabled = enabled
self._render = render
def get_state(self):
"""
返回预设启用状态。
"""
return self._enabled
def get_render_mode(self):
"""
返回预设渲染模式和联邦产物目录。
"""
return self._render
class _Log:
"""
收集投影日志,便于断言非法声明被记录。
"""
def __init__(self):
"""
初始化各级别日志列表。
"""
self.warnings: list[str] = []
self.errors: list[str] = []
def warning(self, message):
"""
记录警告。
"""
self.warnings.append(message)
def error(self, message):
"""
记录错误。
"""
self.errors.append(message)
def _projection(plugins, log=None):
"""
使用真实 remoteEntry 路径构造方式创建投影。
"""
return PluginProjection(
plugins,
log=log or _Log(),
remote_entry_factory=PluginManager.get_plugin_remote_entry,
)
def test_agent_pets_projects_defaults_and_preview_url_next_to_remote_entry():
"""
合法声明补齐默认值,相对预览图与头像解析到与 remoteEntry 相同的静态目录并带版本,缺省为空。
"""
plugin = _Plugin(
plugin_version="1.0.0",
get_agent_pets=lambda: [
{
"key": "chibi",
"name": "看板娘",
"mode": "stage",
"preview": "./img/preview.png",
"avatar": "img/avatar.png",
},
{
"key": "sprite_1",
"name": " 精灵 ",
"description": "传统吉祥物",
"component": "./SpritePet",
"api_version": 2,
"preview": "https://example.com/p.png",
"avatar": "data:image/png;base64,AAAA",
"random_actions": ["wave", "jump", "wave"],
},
{"key": "plain", "name": "无预览"},
],
)
projection = _projection({"PetDemo": plugin})
pets = projection.agent_pets()
remote_url = projection.remotes()[0]["url"]
assert remote_url == "/plugin/file/petdemo/dist/assets/remoteEntry.js?v=1.0.0"
assert pets == [
{
"plugin_id": "PetDemo",
"source_plugin_id": "PetDemo",
"plugin_name": "形象插件",
"plugin_version": "1.0.0",
"remote_url": "/plugin/file/petdemo/dist/assets/remoteEntry.js?v=1.0.0",
"key": "chibi",
"name": "看板娘",
"description": None,
"mode": "stage",
"component": "AgentPet",
"api_version": 1,
"preview_url": "/plugin/file/petdemo/dist/assets/img/preview.png?v=1.0.0",
"avatar_url": "/plugin/file/petdemo/dist/assets/img/avatar.png?v=1.0.0",
"bubbles": "host",
"random_actions": None,
},
{
"plugin_id": "PetDemo",
"source_plugin_id": "PetDemo",
"plugin_name": "形象插件",
"plugin_version": "1.0.0",
"remote_url": "/plugin/file/petdemo/dist/assets/remoteEntry.js?v=1.0.0",
"key": "sprite_1",
"name": "精灵",
"description": "传统吉祥物",
"mode": "renderer",
"component": "SpritePet",
"api_version": 2,
"preview_url": "https://example.com/p.png",
"avatar_url": "data:image/png;base64,AAAA",
"bubbles": None,
"random_actions": ["wave", "jump"],
},
{
"plugin_id": "PetDemo",
"source_plugin_id": "PetDemo",
"plugin_name": "形象插件",
"plugin_version": "1.0.0",
"remote_url": "/plugin/file/petdemo/dist/assets/remoteEntry.js?v=1.0.0",
"key": "plain",
"name": "无预览",
"description": None,
"mode": "renderer",
"component": "AgentPet",
"api_version": 1,
"preview_url": None,
"avatar_url": None,
"bubbles": None,
"random_actions": None,
},
]
# 投影结果必须能被接口 schema 无损承载
assert [PluginAgentPet(**pet).model_dump() for pet in pets] == pets
def test_agent_pets_skips_disabled_non_vue_and_unimplemented_plugins():
"""
停用、非 Vue 渲染或未实现钩子的插件不出现在形象列表中。
"""
# 用匿名函数模拟运行时提供的能力声明。
declared = lambda: [{"key": "pet", "name": "形象"}] # noqa: E731
projection = _projection({
"Disabled": _Plugin(enabled=False, get_agent_pets=declared),
"Vuetify": _Plugin(render=("vuetify", None), get_agent_pets=declared),
"NoHook": _Plugin(),
"Enabled": _Plugin(get_agent_pets=declared),
})
assert [pet["plugin_id"] for pet in projection.agent_pets()] == ["Enabled"]
@pytest.mark.parametrize(
"item",
[
{"key": "Bad-Key", "name": "大写"},
{"key": "a" * 33, "name": "过长"},
{"key": "has space", "name": "空格"},
{"name": "缺 key"},
{"key": "noname"},
{"key": "mode", "name": "未知模式", "mode": "overlay"},
{"key": "bubble", "name": "未知气泡", "mode": "stage", "bubbles": "both"},
{"key": "ver", "name": "版本", "api_version": "1"},
{"key": "ver0", "name": "版本", "api_version": 0},
{"key": "trav", "name": "越界", "preview": "../secret.png"},
{"key": "abs", "name": "根路径", "preview": "/etc/passwd"},
{"key": "js", "name": "脚本", "preview": "javascript:alert(1)"},
{"key": "avatar_trav", "name": "头像越界", "avatar": "img/../../secret.png"},
{"key": "avatar_abs", "name": "头像根路径", "avatar": "/etc/passwd"},
{"key": "avatar_ftp", "name": "头像协议", "avatar": "ftp://example.com/a.png"},
{"key": "avatar_type", "name": "头像类型", "avatar": 1},
{"key": "acts", "name": "动作", "random_actions": "wave"},
"not-a-dict",
],
)
def test_agent_pets_drops_invalid_items_with_warning(item):
"""
任一字段非法的声明整项丢弃并记 warning,同插件其他合法项不受影响。
"""
log = _Log()
projection = _projection(
{"Demo": _Plugin(get_agent_pets=lambda: [item, {"key": "ok", "name": "正常"}])},
log=log,
)
assert [pet["key"] for pet in projection.agent_pets()] == ["ok"]
assert log.warnings and "Demo" in log.warnings[0]
def test_agent_pets_deduplicates_keys_within_plugin_only():
"""
同一插件重复 key 只保留首项,不同插件可使用相同 key。
"""
log = _Log()
projection = _projection(
{
"Alpha": _Plugin(get_agent_pets=lambda: [
{"key": "pet", "name": "第一"},
{"key": "pet", "name": "第二"},
]),
"Beta": _Plugin(get_agent_pets=lambda: [{"key": "pet", "name": "别家"}]),
},
log=log,
)
pets = projection.agent_pets()
assert [(pet["plugin_id"], pet["name"]) for pet in pets] == [("Alpha", "第一"), ("Beta", "别家")]
assert any("重复" in message for message in log.warnings)
def test_agent_pets_instance_keeps_instance_id_and_source_identity():
"""
插件分身沿用实例 ID 作为 plugin_id 与预览 URL 路径,source_plugin_id 指向源插件。
"""
plugin = _Plugin(
plugin_source_id="PetDemo",
get_agent_pets=lambda: [
{"key": "pet", "name": "形象", "preview": "preview.png", "avatar": "avatar.png"}
],
)
pet = _projection({"PetDemoWork": plugin}).agent_pets()[0]
assert pet["plugin_id"] == "PetDemoWork"
assert pet["source_plugin_id"] == "PetDemo"
assert pet["preview_url"] == "/plugin/file/petdemowork/dist/assets/preview.png"
assert pet["avatar_url"] == "/plugin/file/petdemowork/dist/assets/avatar.png"
# 分身的联邦入口也按实例 ID 访问,由静态文件接口映射回源插件目录
assert pet["remote_url"] == "/plugin/file/petdemowork/dist/assets/remoteEntry.js"
assert pet["plugin_version"] is None
def test_agent_pets_isolates_hook_failure_and_non_list_result():
"""
单个插件钩子抛错或返回非列表只记日志,不阻断其他插件。
"""
def fail():
"""
模拟插件钩子异常。
"""
raise RuntimeError("broken")
log = _Log()
projection = _projection(
{
"Broken": _Plugin(get_agent_pets=fail),
"Mapping": _Plugin(get_agent_pets=lambda: {"key": "pet", "name": "错误结构"}),
"Healthy": _Plugin(get_agent_pets=lambda: [{"key": "pet", "name": "形象"}]),
},
log=log,
)
assert [pet["plugin_id"] for pet in projection.agent_pets()] == ["Healthy"]
assert any("Broken" in message for message in log.errors)
assert any("Mapping" in message for message in log.warnings)
def test_agent_pets_endpoint_requires_token_and_precedes_dynamic_plugin_routes(monkeypatch):
"""
接口使用 verify_token 鉴权,且注册在 /{plugin_id} 动态路由之前,返回管理器投影。
"""
signature = inspect.signature(plugin_endpoint.plugin_agent_pets)
assert signature.parameters["_"].default.dependency is verify_token
paths = [route.path for route in plugin_endpoint.router.routes]
assert paths.index("/agent_pets") < paths.index("/{plugin_id}")
expected = [{"plugin_id": "Demo", "key": "pet"}]
monkeypatch.setattr(
plugin_endpoint,
"get_plugin_manager",
lambda: SimpleNamespace(get_plugin_agent_pets=lambda: expected),
)
assert plugin_endpoint.plugin_agent_pets(_=None) is expected
def test_default_agent_pet_setting_is_declared_and_exposed_to_logged_in_users(monkeypatch):
"""
AI_AGENT_PET 进入设置模型与设置目录,并随登录用户全局设置下发给前端。
"""
from app.api.endpoints.system import get_user_global_setting
assert Settings.model_fields["AI_AGENT_PET"].default == ""
spec = ALL_SETTING_SPECS["AI_AGENT_PET"]
assert spec.group == "ai_agent"
assert spec.description
monkeypatch.setattr(settings, "AI_AGENT_PET", "PetDemo:chibi")
runtime = SimpleNamespace(system=SimpleNamespace(user_global=AsyncMock(return_value={})))
response = asyncio.run(get_user_global_setting(_=None, runtime=runtime))
assert response.data["AI_AGENT_PET"] == "PetDemo:chibi"
class _Repository:
"""
记录写入的用户配置仓库替身。
"""
def __init__(self):
"""
初始化写入记录。
"""
self.writes: list[tuple[str, str, object]] = []
def set(self, username, key, value):
"""
记录写入。
"""
self.writes.append((username, key, value))
class _Executor:
"""
同步执行数据库操作的异步执行端口替身。
"""
async def run(self, operation):
"""
直接执行操作。
"""
return operation()
def _state_of_size(size: int) -> dict:
"""
构造紧凑 JSON 序列化后恰为指定字节数的形象数据。
"""
overhead = len(json.dumps({"p": ""}, separators=(",", ":")).encode("utf-8"))
return {"p": "x" * (size - overhead)}
def test_agent_pet_state_prefix_is_accepted_up_to_size_limit():
"""
AgentPetState.* 前缀在 16KB 以内允许写入,AgentPet 选择与其他 key 不受限制。
"""
repository = _Repository()
service = UserConfigurationService(repository, async_executor=_Executor())
at_limit = _state_of_size(AGENT_PET_STATE_MAX_BYTES)
large = _state_of_size(AGENT_PET_STATE_MAX_BYTES + 1)
asyncio.run(service.async_set("alice", "AgentPetState.PetDemo.chibi", at_limit))
service.set("alice", "AgentPet", {"plugin_id": "PetDemo", "key": "chibi"})
service.set("alice", "Dashboard", large)
assert [write[1] for write in repository.writes] == [
"AgentPetState.PetDemo.chibi",
"AgentPet",
"Dashboard",
]
def test_agent_pet_state_over_limit_is_rejected_without_writing():
"""
AgentPetState.* 序列化超过 16KB 时同步与异步写入均被拒绝且不落库。
"""
repository = _Repository()
service = UserConfigurationService(repository, async_executor=_Executor())
too_large = _state_of_size(AGENT_PET_STATE_MAX_BYTES + 1)
with pytest.raises(UserConfigurationValueTooLargeError):
service.set("alice", "AgentPetState.PetDemo.chibi", too_large)
with pytest.raises(UserConfigurationValueTooLargeError):
asyncio.run(service.async_set("alice", "AgentPetState.PetDemo.chibi", too_large))
assert not repository.writes
def test_user_config_endpoint_reports_oversized_agent_pet_state(monkeypatch):
"""
用户配置接口把超限写入转换为失败响应,而不是 500。
"""
repository = _Repository()
service = UserConfigurationService(repository, async_executor=_Executor())
monkeypatch.setattr(user_endpoint, "get_configured_user_configuration", lambda: service)
user = SimpleNamespace(name="alice")
rejected = asyncio.run(user_endpoint.set_config(
key="AgentPetState.PetDemo.chibi",
value=_state_of_size(AGENT_PET_STATE_MAX_BYTES + 1),
current_user=user,
))
accepted = asyncio.run(user_endpoint.set_config(key="AgentPet", value="builtin", current_user=user))
assert rejected.success is False
assert "16384" in rejected.message
assert accepted.success is True
assert repository.writes == [("alice", "AgentPet", "builtin")]
def test_registry_advances_generation_on_enabled_state_change():
"""
启停切换推进前端刷新代次,前端据此重新读取形象声明。
"""
from app.runtime.extensions.plugin.registry import PluginRegistry
registry = PluginRegistry()
before = registry.generation
registry.mark_enabled_state_changed("PetDemo")
assert registry.generation == before + 1