fix(ci): restore architecture and coverage gates

This commit is contained in:
jxxghp
2026-08-31 17:52:12 +08:00
parent 0cfc62005a
commit fecb458302
7 changed files with 638 additions and 130 deletions
+1 -1
View File
@@ -329,7 +329,7 @@ async def run_agent_command(
except ValueError: except ValueError:
channel = None channel = None
try: try:
data = web_agent_application.dispatch_web_agent_command( data = web_agent_application.dispatch_command(
payload.command, payload.command,
user_id=str(current_user.id), user_id=str(current_user.id),
channel=channel, channel=channel,
-2
View File
@@ -949,8 +949,6 @@ def set_plugin_config(
""" """
result = command.update(plugin_id, conf) result = command.update(plugin_id, conf)
return _SchemaResponse(success=result.success, message=result.message) return _SchemaResponse(success=result.success, message=result.message)
@router.delete("/{plugin_id}", summary="卸载插件", response_model=_SchemaResponse[None]) @router.delete("/{plugin_id}", summary="卸载插件", response_model=_SchemaResponse[None])
def uninstall_plugin(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)) -> Any: def uninstall_plugin(plugin_id: str, _: ApiPrincipal = Depends(get_current_active_superuser)) -> Any:
""" """
+235 -104
View File
@@ -1,4 +1,5 @@
from typing import Any, Awaitable, List, Optional from collections.abc import Awaitable, Callable
from typing import Any, List, Optional
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
@@ -43,6 +44,207 @@ async def _require_tmdb_result(operation: Awaitable[List[Any]]) -> List[Any]:
) from error ) from error
def _normalize_agent_media_type(media_type: str) -> Optional[str]:
"""将 Agent 媒体类型转换为推荐链使用的稳定标识。"""
if media_type == "all":
return media_type
media_type_enum = MediaType.from_agent(media_type)
return media_type_enum.to_agent() if media_type_enum else None
async def _fetch_listenbrainz_chart(
chain: RecommendChain,
*,
page: int,
count: int,
range_name: str,
sort_by: str,
min_listen_count: int,
with_cover: bool,
entity: Optional[str],
) -> List[Any] | _SchemaResponse:
"""校验榜单参数并获取 ListenBrainz 榜单结果。"""
if range_name not in LISTENBRAINZ_CHART_RANGES:
return _SchemaResponse(success=False, message="无效的榜单周期")
if sort_by not in {"listen_count.desc", "listen_count.asc"}:
return _SchemaResponse(success=False, message="无效的榜单排序")
return await chain.async_music_chart(
range_name=range_name,
page=page,
count=count,
sort_by=sort_by,
min_listen_count=max(0, min_listen_count),
with_cover=with_cover,
entity=entity or MUSIC_ENTITY_RECORDING,
)
async def _fetch_listenbrainz_fresh(
chain: RecommendChain,
*,
page: int,
count: int,
music_type: Optional[str],
days: int,
fresh_sort: str,
past: bool,
future: bool,
with_cover: bool,
) -> List[Any] | _SchemaResponse:
"""校验新发行参数并获取 ListenBrainz 新发行结果。"""
if music_type not in {None, MUSIC_ENTITY_ALBUM}:
return _SchemaResponse(success=False, message="新发行结果只支持专辑")
if fresh_sort not in LISTENBRAINZ_FRESH_SORTS:
return _SchemaResponse(success=False, message="无效的新发行排序")
if not past and not future:
return _SchemaResponse(
success=False,
message="past 和 future 不能同时为 false",
)
return await chain.async_music_fresh_releases(
days=max(1, min(days, LISTENBRAINZ_FRESH_MAX_DAYS)),
sort=fresh_sort,
past=past,
future=future,
page=page,
count=count,
with_cover=with_cover,
)
async def _recommend_listenbrainz(
chain: RecommendChain,
*,
source: str,
media_type: str,
page: int,
count: int,
music_type: Optional[str],
range_name: str,
sort_by: str,
days: int,
fresh_sort: str,
past: bool,
future: bool,
min_listen_count: int,
with_cover: bool,
) -> _SchemaResponse:
"""执行 ListenBrainz 推荐分支并统一转换音乐结果。"""
if media_type not in {"all", "music"}:
return _SchemaResponse(
success=False,
message="ListenBrainz 来源只支持音乐媒体类型",
)
normalized_music_type = (
normalize_music_type(music_type, allow_artist=False) if music_type else None
)
if music_type and normalized_music_type is None:
return _SchemaResponse(success=False, message="无效的音乐实体类型")
if source == "listenbrainz_chart":
results = await _fetch_listenbrainz_chart(
chain,
page=page,
count=count,
range_name=range_name,
sort_by=sort_by,
min_listen_count=min_listen_count,
with_cover=with_cover,
entity=normalized_music_type,
)
else:
results = await _fetch_listenbrainz_fresh(
chain,
page=page,
count=count,
music_type=normalized_music_type,
days=days,
fresh_sort=fresh_sort,
past=past,
future=future,
with_cover=with_cover,
)
if isinstance(results, _SchemaResponse):
return results
return _SchemaResponse(
success=True,
data=[simplify_music_info(item) for item in results or []],
)
def _media_source_operations(
chain: RecommendChain,
*,
page: int,
count: int,
) -> dict[str, Callable[[], Awaitable[List[Any]]]]:
"""构造影视推荐来源到链方法的延迟调用映射。"""
return {
"tmdb_trending": lambda: chain.async_tmdb_trending(page=page),
"tmdb_movies": lambda: chain.async_tmdb_movies(page=page),
"tmdb_tvs": lambda: chain.async_tmdb_tvs(page=page),
"douban_movie_hot": lambda: chain.async_douban_movie_hot(page=page, count=count),
"douban_tv_hot": lambda: chain.async_douban_tv_hot(page=page, count=count),
"douban_movie_showing": lambda: chain.async_douban_movie_showing(page=page, count=count),
"douban_movies": lambda: chain.async_douban_movies(page=page, count=count),
"douban_tvs": lambda: chain.async_douban_tvs(page=page, count=count),
"douban_movie_top250": lambda: chain.async_douban_movie_top250(page=page, count=count),
"douban_tv_weekly_chinese": lambda: chain.async_douban_tv_weekly_chinese(page=page, count=count),
"douban_tv_weekly_global": lambda: chain.async_douban_tv_weekly_global(page=page, count=count),
"douban_tv_animation": lambda: chain.async_douban_tv_animation(page=page, count=count),
"bangumi_calendar": lambda: chain.async_bangumi_calendar(page=page, count=count),
}
async def _fetch_media_recommendations(
chain: RecommendChain,
*,
source: str,
media_type: str,
page: int,
count: int,
) -> tuple[bool, List[Any]]:
"""获取影视推荐结果,并处理按媒体类型拆分的豆瓣热门来源。"""
if source == "douban_hot":
results: List[Any] = []
if media_type in {"all", "movie"}:
results.extend(await chain.async_douban_movie_hot(page=page, count=count))
if media_type in {"all", "tv"}:
results.extend(await chain.async_douban_tv_hot(page=page, count=count))
return True, results
operation = _media_source_operations(chain, page=page, count=count).get(source)
if operation is None:
return False, []
return True, await operation()
def _project_agent_recommendations(results: List[Any], count: int) -> List[dict[str, Any]]:
"""将推荐链结果裁剪为 Agent 稳定响应字段。"""
projected = []
for item in (results or [])[:count]:
if not isinstance(item, dict):
continue
projected.append(
{
"title": item.get("title"),
"en_title": item.get("en_title"),
"year": item.get("year"),
"type": media_type_to_agent(item.get("type")),
"season": item.get("season"),
"tmdb_id": item.get("tmdb_id"),
"imdb_id": item.get("imdb_id"),
"douban_id": item.get("douban_id"),
"bangumi_id": item.get("bangumi_id"),
"anilist_id": item.get("anilist_id"),
"media_source": item.get("media_source"),
"media_id": item.get("media_id"),
"vote_average": item.get("vote_average"),
"poster_path": item.get("poster_path"),
"detail_link": item.get("detail_link"),
}
)
return projected
@router.get( @router.get(
"/source", "/source",
summary="获取推荐数据源", summary="获取推荐数据源",
@@ -86,116 +288,45 @@ async def agent_recommendations(
"""按稳定来源标识返回有界影视、动画或音乐推荐结果。""" """按稳定来源标识返回有界影视、动画或音乐推荐结果。"""
page = max(1, page) page = max(1, page)
count = 20 count = 20
if media_type != "all": normalized_media_type = _normalize_agent_media_type(media_type)
media_type_enum = MediaType.from_agent(media_type) if normalized_media_type is None:
if media_type_enum is None: return _SchemaResponse(success=False, message="无效的媒体类型")
return _SchemaResponse(success=False, message="无效的媒体类型")
media_type = media_type_enum.to_agent()
chain = RecommendChain() chain = RecommendChain()
if source in {"listenbrainz_chart", "listenbrainz_fresh"}: if source in {"listenbrainz_chart", "listenbrainz_fresh"}:
if media_type not in {"all", "music"}: return await _recommend_listenbrainz(
return _SchemaResponse( chain,
success=False, source=source,
message="ListenBrainz 来源只支持音乐媒体类型", media_type=normalized_media_type,
) page=page,
normalized_music_type = normalize_music_type(music_type, allow_artist=False) if music_type else None count=count,
if music_type and normalized_music_type is None: music_type=music_type,
return _SchemaResponse(success=False, message="无效的音乐实体类型") range_name=range_name,
if source == "listenbrainz_chart": sort_by=sort_by,
if range_name not in LISTENBRAINZ_CHART_RANGES: days=days,
return _SchemaResponse(success=False, message="无效的榜单周期") fresh_sort=fresh_sort,
if sort_by not in {"listen_count.desc", "listen_count.asc"}: past=past,
return _SchemaResponse(success=False, message="无效的榜单排序") future=future,
music_results = await chain.async_music_chart( min_listen_count=min_listen_count,
range_name=range_name, with_cover=with_cover,
page=page,
count=count,
sort_by=sort_by,
min_listen_count=max(0, min_listen_count),
with_cover=with_cover,
entity=normalized_music_type or MUSIC_ENTITY_RECORDING,
)
else:
if normalized_music_type not in {None, MUSIC_ENTITY_ALBUM}:
return _SchemaResponse(
success=False,
message="新发行结果只支持专辑",
)
if fresh_sort not in LISTENBRAINZ_FRESH_SORTS:
return _SchemaResponse(success=False, message="无效的新发行排序")
if not past and not future:
return _SchemaResponse(
success=False,
message="past 和 future 不能同时为 false",
)
music_results = await chain.async_music_fresh_releases(
days=max(1, min(days, LISTENBRAINZ_FRESH_MAX_DAYS)),
sort=fresh_sort,
past=past,
future=future,
page=page,
count=count,
with_cover=with_cover,
)
return _SchemaResponse(
success=True,
data=[simplify_music_info(item) for item in music_results or []],
) )
if media_type == "music": if normalized_media_type == "music":
return _SchemaResponse( return _SchemaResponse(
success=False, success=False,
message="音乐推荐需使用 ListenBrainz 来源", message="音乐推荐需使用 ListenBrainz 来源",
) )
source_calls = { supported, results = await _fetch_media_recommendations(
"tmdb_trending": lambda: chain.async_tmdb_trending(page=page), chain,
"tmdb_movies": lambda: chain.async_tmdb_movies(page=page), source=source,
"tmdb_tvs": lambda: chain.async_tmdb_tvs(page=page), media_type=normalized_media_type,
"douban_movie_hot": lambda: chain.async_douban_movie_hot(page=page, count=count), page=page,
"douban_tv_hot": lambda: chain.async_douban_tv_hot(page=page, count=count), count=count,
"douban_movie_showing": lambda: chain.async_douban_movie_showing(page=page, count=count), )
"douban_movies": lambda: chain.async_douban_movies(page=page, count=count), if not supported:
"douban_tvs": lambda: chain.async_douban_tvs(page=page, count=count), return _SchemaResponse(success=False, message=f"不支持的推荐来源: {source}")
"douban_movie_top250": lambda: chain.async_douban_movie_top250(page=page, count=count), return _SchemaResponse(
"douban_tv_weekly_chinese": lambda: chain.async_douban_tv_weekly_chinese(page=page, count=count), success=True,
"douban_tv_weekly_global": lambda: chain.async_douban_tv_weekly_global(page=page, count=count), data=_project_agent_recommendations(results, count),
"douban_tv_animation": lambda: chain.async_douban_tv_animation(page=page, count=count), )
"bangumi_calendar": lambda: chain.async_bangumi_calendar(page=page, count=count),
}
if source == "douban_hot":
results = []
if media_type in {"all", "movie"}:
results.extend(await chain.async_douban_movie_hot(page=page, count=count))
if media_type in {"all", "tv"}:
results.extend(await chain.async_douban_tv_hot(page=page, count=count))
else:
operation = source_calls.get(source)
if operation is None:
return _SchemaResponse(success=False, message=f"不支持的推荐来源: {source}")
results = await operation()
projected = []
for item in (results or [])[:count]:
if not isinstance(item, dict):
continue
projected.append(
{
"title": item.get("title"),
"en_title": item.get("en_title"),
"year": item.get("year"),
"type": media_type_to_agent(item.get("type")),
"season": item.get("season"),
"tmdb_id": item.get("tmdb_id"),
"imdb_id": item.get("imdb_id"),
"douban_id": item.get("douban_id"),
"bangumi_id": item.get("bangumi_id"),
"anilist_id": item.get("anilist_id"),
"media_source": item.get("media_source"),
"media_id": item.get("media_id"),
"vote_average": item.get("vote_average"),
"poster_path": item.get("poster_path"),
"detail_link": item.get("detail_link"),
}
)
return _SchemaResponse(success=True, data=projected)
@router.get( @router.get(
+1 -19
View File
@@ -48,6 +48,7 @@ from app.runtime.tasks import get_task_registry
from app.schemas.message import Message from app.schemas.message import Message
from app.schemas.types import NotificationChannel, ReplyMode from app.schemas.types import NotificationChannel, ReplyMode
__all__ = ["dispatch_command"]
# Agent 选择按钮回调前缀(新旧两种格式都必须继续兼容) # Agent 选择按钮回调前缀(新旧两种格式都必须继续兼容)
AGENT_CHOICE_PREFIX = "agent_interaction:choice:" AGENT_CHOICE_PREFIX = "agent_interaction:choice:"
LEGACY_AGENT_CHOICE_PREFIX = "agent_choice:" LEGACY_AGENT_CHOICE_PREFIX = "agent_choice:"
@@ -1704,25 +1705,6 @@ def build_web_agent_command_items() -> list[dict[str, Any]]:
) )
return sorted(items, key=lambda item: (item["category"], item["command"])) return sorted(items, key=lambda item: (item["category"], item["command"]))
def dispatch_web_agent_command(
command: str,
*,
user_id: str,
channel: Optional[NotificationChannel],
source: Optional[str],
publish_event: Callable[[Any, dict[str, Any]], Any],
) -> dict[str, Any]:
"""经消息应用边界校验并触发一条 Agent 斜杠命令。"""
return dispatch_command(
command,
user_id=user_id,
channel=channel,
source=source,
publish_event=publish_event,
)
def extract_web_agent_slash_command(text: str) -> Optional[str]: def extract_web_agent_slash_command(text: str) -> Optional[str]:
""" """
从 WebAgent 输入中提取斜杠命令名。 从 WebAgent 输入中提取斜杠命令名。
+1 -1
View File
@@ -103,7 +103,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
| 全量 mypy 历史债务 | 9,983 / 591 文件 | canonical `SearchChain` Facade 已补齐显式类型转发;strict frontier 当前覆盖 41 个文件,低水位只允许继续下降 | | 全量 mypy 历史债务 | 9,983 / 591 文件 | canonical `SearchChain` Facade 已补齐显式类型转发;strict frontier 当前覆盖 41 个文件,低水位只允许继续下降 |
| Ruff 历史诊断 | 630 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | Ruff 历史诊断 | 630 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 81.86%Domain 81.03% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | | 覆盖率低水位 | Application 81.88%Domain 81.03% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
### 3.3 热点文件 ### 3.3 热点文件
+3 -3
View File
@@ -1,8 +1,8 @@
{ {
"application": { "application": {
"covered_lines": 12996, "covered_lines": 13687,
"percent": 81.86, "percent": 81.88,
"statements": 15875 "statements": 16716
}, },
"domain": { "domain": {
"covered_lines": 3686, "covered_lines": 3686,
+397
View File
@@ -0,0 +1,397 @@
"""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.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,
build_preview_payload,
clamp_preview_chars,
)
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.async_set = AsyncMock(return_value=True)
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, ""], publish)
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"] == "***"
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
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
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")
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")