Files
MoviePilot/tests/test_agent_application_services.py

655 lines
30 KiB
Python

"""Agent 应用服务的纯逻辑、端口编排和安全投影测试。"""
from contextlib import asynccontextmanager, nullcontext
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
import app.application.commands as command_application
import app.application.filtering as filtering
import app.application.plugin.management as plugin_management
import app.application.settings as settings_module
from app.application.configuration import SystemConfigWriteResult
from app.application.download.tasks import DownloadTaskMutationService, DownloadTaskService
from app.application.music.projection import simplify_music_album, simplify_music_artist, simplify_music_info
from app.application.plugin.data import (
DeletePluginDataCommand,
PluginDataQueryService,
PluginDataSummaryService,
build_preview_payload,
clamp_preview_chars,
plugin_data_serialized_chars,
plugin_data_value_type,
)
from app.application.security.secrets import is_secret_setting_key
from app.domain.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo, MusicRelease
from app.domain.projection.douban import project as project_douban
from app.schemas.rule import CustomRule, FilterRuleGroup
from app.schemas.types import EventType, MediaSource, SystemConfigKey
def run_async(awaitable):
"""在普通 pytest 函数中执行一个短异步断言。"""
import asyncio
return asyncio.run(awaitable)
def test_filtering_primitives_and_projection():
"""过滤规则基础函数应统一输入、解析引用并隔离可变输出。"""
assert filtering.normalize_optional_text(None) is None
assert filtering.normalize_optional_text(" value ") == "value"
assert filtering.normalize_media_type("movie") == "电影"
assert filtering.normalize_media_type("电视剧") == "电视剧"
assert filtering.normalize_media_type("") is None
with pytest.raises(ValueError, match="media_type"):
filtering.normalize_media_type("documentary")
assert filtering.validate_numeric_range("size_range", None) is None
assert filtering.validate_numeric_range("size_range", "1000 - 5000") == "1000 - 5000"
with pytest.raises(ValueError, match="格式无效"):
filtering.validate_numeric_range("size_range", "abc")
with pytest.raises(ValueError, match="起始值"):
filtering.validate_numeric_range("size_range", "5-2")
assert filtering.validate_seeders(" 12 ") == "12"
with pytest.raises(ValueError, match="非负整数"):
filtering.validate_seeders("1.2")
builtin = filtering.get_builtin_rules()
builtin["4K"]["_test"] = "changed"
assert "_test" not in filtering.get_builtin_rules()["4K"]
custom = CustomRule(id="CUSTOM", name="Custom", include="x")
group = FilterRuleGroup(name="main", rule_string="CUSTOM & 4K")
assert filtering.build_custom_rule_map([custom, CustomRule(id=None)]) == {"CUSTOM": custom}
assert filtering.build_rule_group_map([group, FilterRuleGroup(name=None)]) == {"main": group}
assert filtering.extract_rule_tokens("CUSTOM & 4K & CUSTOM") == ["CUSTOM", "4K"]
assert filtering.extract_rule_tokens(None) == []
assert filtering.parse_rule_string("CUSTOM & 4K > !BLU")["levels"][0]["priority"] == 1
with pytest.raises(ValueError, match="不能为空"):
filtering.parse_rule_string(" ")
with pytest.raises(ValueError, match="空层级"):
filtering.parse_rule_string("4K > ")
assert filtering.validate_rule_string("CUSTOM & 4K", ["CUSTOM", "4K"])["levels"]
with pytest.raises(ValueError, match="不存在"):
filtering.validate_rule_string("MISSING", ["4K"])
assert filtering.serialize_builtin_rule("4K", {"name": "4K"})["source"] == "builtin"
assert filtering.serialize_custom_rule(custom, ["main"])["referenced_by_rule_groups"] == ["main"]
assert filtering.serialize_rule_group(group)["syntax_valid"] is True
assert filtering.serialize_rule_group(FilterRuleGroup(name="bad", rule_string="4K > "))["syntax_valid"] is False
assert filtering.serialize_rule_group(FilterRuleGroup(name="empty"))["syntax_valid"] is False
assert filtering.replace_rule_id_in_rule_string("OLD & OLDER", "OLD", "NEW") == "NEW & OLDER"
def test_filtering_normalizers_and_usage_collection(monkeypatch):
"""规则实体校验应拒绝重复项,并能汇总全局与订阅引用。"""
rule = filtering.normalize_custom_rule("NEW", "New rule", "inc", None, "1-2", "3", "2024", [])
assert rule.id == "NEW"
with pytest.raises(ValueError, match="不能为空"):
filtering.normalize_custom_rule("", "New", None, None, None, None, None, [])
with pytest.raises(ValueError, match="不能为空"):
filtering.normalize_custom_rule("NEW", "", None, None, None, None, None, [])
with pytest.raises(ValueError, match="内置"):
filtering.normalize_custom_rule("4K", "Built-in", None, None, None, None, None, [])
with pytest.raises(ValueError, match="已存在"):
filtering.normalize_custom_rule("NEW", "Other", None, None, None, None, None, [rule])
with pytest.raises(ValueError, match="规则名称"):
filtering.normalize_custom_rule("OTHER", "New rule", None, None, None, None, None, [rule])
with pytest.raises(ValueError, match="rule_id"):
filtering.normalize_custom_rule("bad-id", "Bad", None, None, None, None, None, [])
with pytest.raises(ValueError, match="规则组名称"):
filtering.normalize_rule_group("", "4K", None, None, [], ["4K"])
with pytest.raises(ValueError, match="category"):
filtering.normalize_rule_group("group", "4K", None, "movie", [], ["4K"])
normalized_group, parsed = filtering.normalize_rule_group("group", "4K", "movie", "action", [], ["4K"])
assert normalized_group.media_type == "电影" and parsed["levels"]
with pytest.raises(ValueError, match="已存在"):
filtering.normalize_rule_group("group", "4K", None, None, [normalized_group], ["4K"])
config = MagicMock()
config.get.side_effect = lambda key: {
SystemConfigKey.SearchFilterRuleGroups: ["search"],
SystemConfigKey.SubscribeFilterRuleGroups: ["subscribe"],
SystemConfigKey.BestVersionFilterRuleGroups: ["best"],
}.get(key, [])
monkeypatch.setattr(filtering, "get_configured_system_config", lambda: config)
class SubscriptionPort:
"""提供规则组使用统计所需的最小订阅端口。"""
async def async_list(self):
"""返回带规则组引用的订阅快照。"""
return [
SimpleNamespace(
id=1,
name="Sub",
season=1,
type="电影",
username="u",
best_version=True,
filter_groups=["search", "custom"],
),
SimpleNamespace(filter_groups=None),
]
usage = run_async(filtering.collect_rule_group_usages(SubscriptionPort(), ["search", "custom"]))
assert usage["search"]["used_in_global_search"] is True
assert usage["search"]["subscribes"][0]["subscribe_id"] == 1
refs = filtering.collect_custom_rule_group_refs(
[FilterRuleGroup(name="main", rule_string="CUSTOM & 4K"), FilterRuleGroup(name="none")], ["CUSTOM"]
)
assert refs["CUSTOM"] == ["main"]
@pytest.mark.asyncio
async def test_filter_rule_service_queries_and_mutations(monkeypatch):
"""规则服务应覆盖查询、增删改和重命名引用的事务边界。"""
old_rule = CustomRule(id="OLD", name="Old", include="old")
old_group = FilterRuleGroup(name="group", rule_string="OLD & 4K")
monkeypatch.setattr(filtering, "get_custom_rules", lambda: [old_rule])
monkeypatch.setattr(filtering, "get_rule_groups", lambda: [old_group])
config = MagicMock()
config.async_set = AsyncMock(return_value=True)
monkeypatch.setattr(filtering, "get_configured_system_config", lambda: config)
publish = AsyncMock()
mutation = MagicMock()
mutation.apply = AsyncMock(return_value=SimpleNamespace(to_dict=lambda: {"changed": True}))
@asynccontextmanager
async def mutation_scope():
"""提供可观测的规则组异步事务。"""
yield mutation
class SubscriptionPort:
"""提供空订阅列表的查询端口。"""
async def async_list(self):
"""返回空订阅集合。"""
return []
service = filtering.FilterRuleService(SubscriptionPort(), mutation_scope, publish)
assert filtering.FilterRuleService.query_builtin(["4K"])["count"] == 1
assert filtering.FilterRuleService.query_custom(["OLD"])["count"] == 1
assert (await service.query_groups(include_usage=False))["count"] == 1
assert (await service.query_groups(["missing"], include_usage=False))["count"] == 0
added = await service.add_custom(
rule_id="NEW", name="New", include="new", exclude=None, size_range=None, seeders=None, publish_time=None
)
assert added["custom_rule"]["id"] == "NEW"
updated = await service.update_custom(current_rule_id="OLD", new_rule_id="RENAMED")
assert updated["rule_groups_updated_for_rule_id_rename"] == ["group"]
monkeypatch.setattr(filtering, "get_custom_rules", lambda: [CustomRule(id="DELETE", name="Delete")])
monkeypatch.setattr(filtering, "get_rule_groups", lambda: [])
assert (await service.delete_custom("DELETE"))["count"] == 0
assert (await service.add_group(name="new-group", rule_string="4K"))["rule_group"]["name"] == "new-group"
monkeypatch.setattr(filtering, "get_rule_groups", lambda: [FilterRuleGroup(name="old", rule_string="4K")])
assert (await service.update_group(current_name="old", new_name="new"))["rule_group"]["name"] == "new"
assert (await service.delete_group("old"))["count"] == 0
monkeypatch.setattr(filtering, "get_rule_groups", lambda: [])
with pytest.raises(ValueError, match="不存在"):
await service.delete_group("old")
with pytest.raises(ValueError, match="不存在"):
await service.delete_custom("missing")
@pytest.mark.asyncio
async def test_save_system_config_and_settings_service(monkeypatch):
"""系统设置服务应处理摘要、脱敏、合并、列表更新和两类配置源。"""
runtime = MagicMock()
runtime.get.side_effect = lambda key: {"LLM_MODEL": "model", "PLUGIN_MARKET": "a"}.get(key)
runtime.update.return_value = (True, "updated")
system = MagicMock()
system.get.side_effect = lambda key: (
[{"name": "qb", "token": "secret"}] if key == SystemConfigKey.Downloaders else {"a": 1}
)
system.normalize_value.side_effect = lambda _key, value: value
system.async_set = AsyncMock(return_value=True)
system.async_set_with_normalized_value = AsyncMock(
side_effect=lambda _key, value: SystemConfigWriteResult(
changed=True,
normalized_value=value,
)
)
publish = AsyncMock()
filter_config = MagicMock()
filter_config.async_set = AsyncMock(return_value=True)
monkeypatch.setattr(filtering, "get_configured_system_config", lambda: filter_config)
monkeypatch.setattr(settings_module, "plugin_system_config_mutation", lambda _key: nullcontext())
service = settings_module.SystemSettingsService(runtime, system, publish)
await filtering.save_system_config(SystemConfigKey.CustomFilterRules, [None, ""])
assert filter_config.async_set.await_count == 1
secret = service.query(setting_key=SystemConfigKey.Downloaders.value, include_values=True)
assert secret["settings"][0]["value"][0]["token"] == "***"
definition = secret["settings"][0]["definition"]
assert definition["declared_type"].startswith("list[")
assert definition["value_shape"] == "list"
assert definition["nullable"] is True
assert definition["sensitive"] is True
assert definition["generic_write_allowed"] is True
assert definition["update_operations"] == ["replace", "upsert_list_item", "remove_list_item"]
assert definition["default_match_field"] == "name"
assert definition["persistence"] == "database:systemconfig"
assert any(item.get("type") == "array" for item in definition["value_schema"]["anyOf"])
assert definition["description"]
shown = service.query(setting_key=SystemConfigKey.Downloaders.value, include_values=True, show_secrets=True)
assert shown["settings"][0]["value"][0]["token"] == "secret"
assert service.query(group="ai_agent")["include_values"] is False
runtime_definition = service.query(setting_key="LLM_MODEL")["settings"][0]["definition"]
assert runtime_definition["declared_type"]
assert runtime_definition["value_shape"] == "str"
assert runtime_definition["update_operations"] == ["replace"]
assert runtime_definition["persistence"] == "app.env"
spec = settings_module.resolve_setting_spec(SystemConfigKey.Downloaders.value)
assert spec
assert service._prepare_next_value(
spec, {"name": "old", "x": 1}, {"name": "old", "y": 2}, "merge_dict", ["x"], None, None
) == {"name": "old", "y": 2}
assert service._prepare_next_value(
spec, [{"name": "old"}], {"name": "old", "x": 2}, "upsert_list_item", None, None, None
) == [{"name": "old", "x": 2}]
assert (
service._prepare_next_value(spec, [{"name": "old"}], {"name": "old"}, "remove_list_item", None, None, None)
== []
)
with pytest.raises(ValueError, match="不支持"):
service._prepare_next_value(spec, None, None, "bad", None, None, None)
system.get.side_effect = [[], [{"name": "new"}]]
result = await service.update(
setting_key=SystemConfigKey.Downloaders.value, value={"name": "new"}, operation="upsert_list_item"
)
assert result["changed"] is True
runtime.get.side_effect = lambda key: "old"
assert (await service.update(setting_key="PLUGIN_MARKET", value="new"))["changed"] is True
@pytest.mark.asyncio
async def test_settings_service_publishes_normalized_directory_value(monkeypatch):
"""新设置入口写库与配置事件必须共享同一份目录规范化结果。"""
runtime = MagicMock()
system = MagicMock()
system.get.side_effect = [[], [{"name": "动漫", "media_category": "动漫/日番"}]]
normalized = [{"name": "动漫", "media_category_id": "tv.anime.jp", "media_category": "动漫/日番"}]
system.async_set_with_normalized_value = AsyncMock(
return_value=SystemConfigWriteResult(
changed=True,
normalized_value=normalized,
)
)
publish = AsyncMock()
monkeypatch.setattr(settings_module, "plugin_system_config_mutation", lambda _key: nullcontext())
service = settings_module.SystemSettingsService(runtime, system, publish)
result = await service.update(
setting_key=SystemConfigKey.Directories.value,
value={"name": "动漫", "media_category_id": "tv.anime.jp"},
operation="upsert_list_item",
)
assert result["saved_value"] == [{"name": "动漫", "media_category": "动漫/日番"}]
system.async_set_with_normalized_value.assert_awaited_once_with(
SystemConfigKey.Directories,
[{"name": "动漫", "media_category_id": "tv.anime.jp"}],
)
publish.assert_awaited_once_with(SystemConfigKey.Directories.value, normalized)
@pytest.mark.asyncio
async def test_settings_service_conditionally_replaces_system_config_atomically(monkeypatch):
"""条件替换应拒绝过期快照,并只为成功提交发布配置事件。"""
runtime = MagicMock()
system = MagicMock()
state = {"value": ["old"]}
def get_value(_key):
"""返回原子测试维护的最新配置快照。"""
return list(state["value"])
async def update_atomically(_key, mutation):
"""在测试内同步执行条件 mutation 并发布最终值。"""
result, value = mutation(list(state["value"]))
state["value"] = list(value or [])
return result
system.get.side_effect = get_value
system.async_update_atomically = AsyncMock(side_effect=update_atomically)
publish = AsyncMock()
monkeypatch.setattr(settings_module, "plugin_system_config_mutation", lambda _key: nullcontext())
service = settings_module.SystemSettingsService(runtime, system, publish)
result = await service.update(
setting_key=SystemConfigKey.CustomIdentifiers.value,
value=["new"],
expected_value=["old"],
enforce_expected_value=True,
)
assert result["changed"] is True
assert result["previous_value"] == ["old"]
assert result["saved_value"] == ["new"]
publish.assert_awaited_once_with(SystemConfigKey.CustomIdentifiers.value, ["new"])
state["value"] = ["new", 7]
repaired = await service.update(
setting_key=SystemConfigKey.CustomIdentifiers.value,
value=["fixed"],
expected_value=["new"],
enforce_expected_value=True,
)
assert repaired["saved_value"] == ["fixed"]
with pytest.raises(settings_module.SystemSettingConflictError, match="其他会话"):
await service.update(
setting_key=SystemConfigKey.CustomIdentifiers.value,
value=["mine"],
expected_value=["stale"],
enforce_expected_value=True,
)
assert state["value"] == ["fixed"]
assert publish.await_count == 2
def test_settings_catalog_redaction_and_projection():
"""设置目录应支持分类别名、匹配字段和递归敏感值脱敏。"""
assert settings_module.normalize_group("基础配置") == "settings"
assert settings_module.normalize_group("全部") == "all"
with pytest.raises(ValueError, match="group"):
settings_module.normalize_group("unknown")
assert settings_module.resolve_setting_spec("Downloaders") is not None
assert settings_module.list_setting_specs("downloaders")[0].group == "downloaders"
assert settings_module.list_setting_specs("ai_agent", keyword="llm")
assert settings_module.get_default_list_match_field(SystemConfigKey.Downloaders.value) == "name"
assert settings_module.redact_secret_value({"apiKey": "secret", "url": "x", "nested": ["plain"]})["apiKey"] == "***"
assert settings_module.normalize_group(None) == "all"
assert settings_module.resolve_setting_spec(None) is None
spec = settings_module.resolve_setting_spec(SystemConfigKey.UserSiteAuthParams.value)
assert spec and settings_module.should_redact_setting(spec, [{"token": "secret"}])
assert is_secret_setting_key("accessToken") and not is_secret_setting_key("token_count")
@pytest.mark.asyncio
async def test_plugin_management_and_data_services(monkeypatch):
"""插件管理、来源补齐和数据预览应覆盖成功与安全失败路径。"""
plugin = SimpleNamespace(
id="Demo",
plugin_name="Demo Plugin",
plugin_desc="desc",
plugin_version="1",
plugin_author="author",
installed=True,
has_update=True,
state=True,
repo_url=None,
add_time=1,
)
class SourceCandidate:
"""提供插件来源检查所需的公开投影。"""
id = "Demo"
plugin_name = "Demo Plugin"
repo_url = "https://github.com/demo/repo"
has_update = True
release = "r1"
system_version = "3"
system_version_compatible = True
system_version_message = None
def public_dict(self):
"""返回来源候选的脱敏字典。"""
return {"id": self.id, "repo_url": self.repo_url}
source = SourceCandidate()
manager = MagicMock()
manager.get_local_plugins.return_value = [plugin]
manager.get_local_repo_plugins.return_value = [source]
manager.async_get_online_plugins = AsyncMock(return_value=[])
manager.process_plugins_list.return_value = [source]
monkeypatch.setattr(plugin_management, "get_plugin_manager", lambda: manager)
assert plugin_management.get_plugin_snapshot("Demo")["plugin_id"] == "Demo"
assert plugin_management.summarize_plugin(plugin)["source"] == "market"
assert plugin_management.is_exact_plugin_match(plugin, "demo plugin")
assert plugin_management.search_plugin_candidates("demo", [plugin])[0]["exact"] is True
assert (
plugin_management.summarize_candidates(plugin_management.search_plugin_candidates("demo", [plugin]), 1)[0]["id"]
== "Demo"
)
assert await plugin_management.enrich_installed_plugin_sources([plugin]) == [plugin]
assert plugin.repo_url == source.repo_url
assert await plugin_management.load_market_plugins() == [source]
assert plugin_management.list_installed_plugins() == [plugin]
install_service = MagicMock()
install_service.install = AsyncMock(return_value=SimpleNamespace(success=True, message="ok", refreshed_only=False))
install_service.inspect_source = AsyncMock(
return_value=SimpleNamespace(
online_candidates=[source],
local_candidate=None,
selection=SimpleNamespace(status=SimpleNamespace(value="selected"), reason="exact"),
inventory_complete=True,
)
)
monkeypatch.setattr(plugin_management, "get_plugin_install_service", lambda: install_service)
assert await plugin_management.install_plugin_runtime("Demo", source.repo_url) == (True, "ok", False)
assert (await plugin_management.inspect_plugin_sources("Demo"))["selection_status"] == "selected"
repo = MagicMock()
unit = MagicMock()
DeletePluginDataCommand(repo, unit).execute("Demo")
unit.commit.assert_called_once()
assert clamp_preview_chars(1) == 512
truncated, total, returned, preview = build_preview_payload({"value": "x" * 1000}, 512)
assert truncated and total > returned and "截断" in preview
query_repo = MagicMock()
query_repo.get = AsyncMock(return_value={"key": "value"})
query_repo.list = AsyncMock(return_value={"a": 1})
query = PluginDataQueryService(query_repo, lambda _id: {"plugin_id": "Demo"})
assert (await query.query("Demo", key="config"))["found"]
query_repo.get.return_value = None
assert not (await query.query("Demo", key="missing"))["found"]
assert (await query.query("Demo"))["count"] == 1
with pytest.raises(ValueError, match="不存在"):
await PluginDataQueryService(query_repo, lambda _id: None).query("Demo")
@pytest.mark.asyncio
async def test_plugin_data_summary_excludes_values_and_flags_sensitive_keys():
"""插件数据摘要只返回类型与大小,并沿用统一敏感键规则。"""
query_repo = MagicMock()
query_repo.list = AsyncMock(
return_value={
"accessToken": "secret-value",
"items": [{"id": 1}],
"enabled": True,
}
)
service = PluginDataSummaryService(
query_repo,
lambda _id: {
"plugin_id": "Demo",
"plugin_name": "Demo Plugin",
"plugin_version": "1.0.0",
"state": True,
},
)
result = await service.summarize("Demo")
assert result == {
"plugin_id": "Demo",
"plugin_name": "Demo Plugin",
"plugin_version": "1.0.0",
"state": True,
"count": 3,
"total_chars": len('"secret-value"') + len('[{"id":1}]') + len("true"),
"keys": [
{
"key": "accessToken",
"value_type": "string",
"serialized_chars": len('"secret-value"'),
"sensitive": True,
},
{
"key": "items",
"value_type": "array",
"serialized_chars": len('[{"id":1}]'),
"sensitive": False,
},
{
"key": "enabled",
"value_type": "boolean",
"serialized_chars": len("true"),
"sensitive": False,
},
],
"keys_truncated": False,
}
assert "secret-value" not in repr(result["keys"])
assert plugin_data_value_type(None) == "null"
assert plugin_data_serialized_chars(object()) is None
def test_remaining_application_guard_paths(monkeypatch):
"""应用服务的空结果、回滚和匹配参数保护应保持可观测。"""
helper = MagicMock()
helper.get_custom_rules.return_value = []
helper.get_rule_groups.return_value = []
monkeypatch.setattr(filtering, "RuleHelper", lambda: helper)
assert filtering.get_custom_rules() == []
assert filtering.get_rule_groups() == []
assert settings_module.SystemSettingsService._normalize_systemconfig_value([]) is None
with pytest.raises(ValueError, match="匹配字段"):
settings_module.SystemSettingsService._resolve_list_match(
settings_module.SettingSpec("CUSTOM", "settings", "x", "x"),
"upsert_list_item",
{"value": 1},
None,
None,
)
empty_service = DownloadTaskService(
MagicMock(return_value=[]),
lambda _hashes: {},
MagicMock(),
MagicMock(),
MagicMock(),
)
assert empty_service.downloading() == []
def test_command_application_facade_and_dispatch(monkeypatch):
"""命令应用门面应支持注册、查询、初始化和事件派发。"""
class CommandRegistry:
"""提供命令门面所需的最小注册表实现。"""
calls = []
def get_commands(self):
"""返回一个可触发的命令定义。"""
return {"/demo": {"description": "Demo", "pid": "plugin"}}
def get(self, name):
"""按名称返回命令定义。"""
return self.get_commands().get(name)
def init_commands(self, plugin_id=None):
"""记录命令初始化范围。"""
self.calls.append(plugin_id)
command_application.register_command_class(CommandRegistry)
try:
assert isinstance(command_application.get_command_object(), CommandRegistry)
assert command_application.get_commands()["/demo"]["description"] == "Demo"
assert command_application.get_command("/demo")["pid"] == "plugin"
command_application.init_commands("plugin")
published = []
result = command_application.dispatch_command(
"demo --flag",
user_id="1",
source="test",
publish_event=lambda event_type, payload: published.append((event_type, payload)),
)
assert result["command"] == "/demo --flag"
assert result["plugin_id"] == "plugin"
assert published == [
(
EventType.CommandExcute,
{"cmd": "/demo --flag", "user": "1", "channel": None, "source": "test"},
)
]
with pytest.raises(ValueError, match="命令不能为空"):
command_application.dispatch_command(" ", user_id="1", publish_event=lambda *_args: None)
with pytest.raises(ValueError, match="不存在"):
command_application.dispatch_command("/missing", user_id="1", publish_event=lambda *_args: None)
finally:
command_application.reset_command_class()
with pytest.raises(RuntimeError, match="未初始化"):
command_application.get_command_object()
def test_music_projection_and_domain_projection():
"""音乐和豆瓣投影应保留稳定身份并裁剪大字段。"""
track = MusicInfo(
media_source=MediaSource.MusicBrainz, media_id="track", title="Track", artists=["Artist"], year=2024
)
assert simplify_music_info(track)["title"] == "Track"
album = MusicAlbumInfo(
media_source=MediaSource.MusicBrainz,
media_id="album",
title="Album",
artists=["Artist"],
tracks=[track] * 3,
releases=[MusicRelease(media_id="release", title="Release")],
)
assert simplify_music_album(album, track_limit=2)["tracks_truncated"] is True
artist = MusicArtistInfo(
media_source=MediaSource.MusicBrainz, media_id="artist", name="Artist", raw_data={"secret": 1}
)
assert simplify_music_artist(artist)["subscribable"] is False
projected = project_douban(
{}, {"id": "1", "title": "Movie", "subtype": "movie", "rating": {"value": 8.5}, "pic": {"large": "poster"}}
)
assert projected["poster_path"] == "poster"
assert projected["douban_id"] == "1"
assert project_douban({}, {}) == {}
def test_download_task_services_validate_and_delegate(monkeypatch):
"""下载任务服务应补齐历史媒体并严格校验高级修改。"""
torrent = SimpleNamespace(hash="a" * 40, downloader="qb")
history = SimpleNamespace(
media_source=MediaSource.TMDB,
media_id="1",
type="电影",
title="Movie",
seasons="1",
episodes="2",
poster="p",
image="b",
torrent_site="site",
userid="u",
username="name",
)
list_torrents = MagicMock(return_value=[torrent])
service = DownloadTaskService(
list_torrents,
lambda _hashes: {torrent.hash: history},
MagicMock(return_value=True),
MagicMock(return_value=True),
MagicMock(return_value=True),
)
assert service.downloading()[0].media.title == "Movie"
assert service.set_downloading(torrent.hash, "start") is True
assert service.set_downloading(torrent.hash, "bad") is False
assert service.remove_downloading(torrent.hash) is True
mutation = DownloadTaskMutationService(
list_torrents=lambda **_kwargs: [torrent],
set_tags=MagicMock(return_value=True),
set_downloading=MagicMock(return_value=True),
update_torrent=MagicMock(return_value={"limits": True, "trackers": False}),
)
assert mutation.update(hash_value=torrent.hash, action="start", tags=["tag"], download_limit=1)["results"]
with pytest.raises(ValueError, match="hash"):
mutation.update(hash_value="bad", action="start")
with pytest.raises(ValueError, match="至少"):
mutation.update(hash_value=torrent.hash)
with pytest.raises(ValueError, match="action"):
mutation.update(hash_value=torrent.hash, action="pause")