Files
MoviePilot/tests/test_trimemedia_coverage.py
JontyLee 68d13fc5b2 fix(trimemedia): 访问码校验失败日志不再记录设备访问码
verify_access_code 把设备访问码拼进校验地址的路径段(/c/<code>),一旦连不上
就把整条 URL 写进 error 日志,明文凭据随之落到实例日志文件里;404 与状态码两
个分支本来就不带访问码,这里补齐同一条边界:只记录设备根地址。

这是 #7157 之后对其余裸 {url} 日志点做完整评估时唯一确认会写入凭据的一处:其余
调用点记录的是站点根地址,或把 api_key 经 params 传递、URL 本身不含凭据。
2026-10-26 20:49:21 +08:00

174 lines
5.8 KiB
Python

"""
飞牛媒体服务器类型迁移后的边界分支测试。
测试只使用本地替身,不连接真实飞牛服务。
"""
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app.modules.trimemedia import api as trimemedia_api
from app.modules.trimemedia.module import TrimeMediaModule
from app.modules.trimemedia.trimemedia import TrimeMedia
from app.schemas.types import MediaSource
def _authenticated_client() -> TrimeMedia:
"""
构造一个带有最小认证状态的飞牛客户端替身。
"""
client = TrimeMedia.__new__(TrimeMedia)
client._host = "https://trimemedia.example"
client._username = "admin"
client._password = "password"
client._access_code = "access"
client._playhost = None
client._sync_libraries = []
client._userinfo = trimemedia_api.User("user", "admin", 1)
client._api = SimpleNamespace(
host="https://trimemedia.example",
token="token",
cookies={"Trim-MC-access": "access"},
)
client.is_authenticated = Mock(return_value=True)
return client
def test_authenticate_returns_none_when_probe_fails(monkeypatch):
"""
临时认证 API 探测失败时返回 None。
"""
client = TrimeMedia.__new__(TrimeMedia)
client._host = "https://trimemedia.example"
client._username = "admin"
client._password = "password"
monkeypatch.setattr(
TrimeMedia,
"_TrimeMedia__create_api",
staticmethod(lambda *_args: None),
)
assert client.authenticate("admin", "password") is None
def test_get_items_yields_nested_directory_items():
"""
媒体库目录应递归展开,并只产出电影条目。
"""
client = _authenticated_client()
root = trimemedia_api.Item(guid="root", type=trimemedia_api.Type.DIRECTORY)
movie = trimemedia_api.Item(guid="movie", type=trimemedia_api.Type.MOVIE)
client._api.item_list = Mock(side_effect=[[root], [movie]])
client._TrimeMedia__build_media_server_item = Mock(return_value="movie-result")
assert list(client.get_items("library")) == ["movie-result"]
assert client._api.item_list.call_args_list[1].kwargs["guid"] == "root"
def test_get_tv_episodes_skips_incomplete_episode_numbers():
"""
飞牛返回缺少季号或集号的条目时,只保留可用于统一契约的完整编号。
"""
client = _authenticated_client()
client.get_iteminfo = Mock(
return_value=SimpleNamespace(media_source=MediaSource.TMDB, media_id="1")
)
client._api.season_list = Mock(
return_value=[SimpleNamespace(guid="season", season_number=1)]
)
client._api.episode_list = Mock(
return_value=[
SimpleNamespace(season_number=None, episode_number=1),
SimpleNamespace(season_number=1, episode_number=None),
SimpleNamespace(season_number=1, episode_number=2),
]
)
assert client.get_tv_episodes(item_id="series", media_source=MediaSource.TMDB, media_id="1") == (
"series",
{1: [2]},
)
@pytest.mark.parametrize("method", ["get_latest", "get_latest_backdrops"])
def test_latest_rejects_none_count_like_previous_arithmetic(method):
"""
最新列表的 None 数量仍在原有乘法位置报告 TypeError。
"""
client = _authenticated_client()
with pytest.raises(TypeError, match="NoneType"):
getattr(client, method)(num=None)
def test_get_items_rejects_none_start_index_like_previous_arithmetic():
"""
媒体列表的 None 起始位置仍保持原有加法失败语义。
"""
client = _authenticated_client()
with pytest.raises(TypeError, match="NoneType"):
list(client.get_items("library", start_index=None))
def test_get_resume_builds_play_item_and_get_image_cookies(monkeypatch):
"""
继续观看条目应生成播放卡片,图片请求应返回带访问码的 Cookie。
"""
client = _authenticated_client()
client._api.play_list = Mock(
return_value=[
trimemedia_api.Item(
guid="movie",
type=trimemedia_api.Type.MOVIE,
title="电影",
poster="/poster.jpg",
)
]
)
resume = client.get_resume(num=1)
assert resume and resume[0].id == "movie"
monkeypatch.setattr("app.modules.trimemedia.trimemedia.SecurityUtils.is_safe_url", lambda *_args, **_kwargs: True)
assert client.get_image_cookies("https://trimemedia.example/poster.jpg") == {
"Trim-MC-token": "token",
"Trim-MC-access": "access",
}
def test_module_image_cookies_returns_none_when_no_instance_owns_image(monkeypatch):
"""
所有飞牛实例均拒绝图片地址时,模块不提供其它实例的授权凭据。
:param monkeypatch: 隔离模块实例目录
"""
module = TrimeMediaModule()
server = SimpleNamespace(get_image_cookies=Mock(return_value=None))
monkeypatch.setattr(module, "get_instances", lambda: {"first": server})
assert module.mediaserver_image_cookies(image_url="https://other.example/poster.jpg") is None
server.get_image_cookies.assert_called_once_with("https://other.example/poster.jpg")
def test_verify_access_code_failure_log_omits_access_code(monkeypatch):
"""
访问码是校验地址的路径段,连接失败时日志只记录设备根地址。
:param monkeypatch: 隔离 HTTP 会话与日志输出
"""
api = trimemedia_api.Api(
host="https://trimemedia.example/v",
apikey="apikey",
access_code="device-access-code",
)
logs = Mock()
monkeypatch.setattr(trimemedia_api, "logger", logs)
monkeypatch.setattr(api._request_utils, "get_res", lambda *_args, **_kwargs: None)
assert api.verify_access_code() is False
message = logs.error.call_args[0][0]
assert "device-access-code" not in message
assert "https://trimemedia.example" in message