mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-01 05:27:02 +08:00
317 lines
10 KiB
Python
317 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
|
# pylint: disable=no-name-in-module
|
|
|
|
import base64
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.chain.site import SiteChain
|
|
from app.modules.indexer.parser.rousi import RousiSiteUserInfo
|
|
from app.modules.indexer.spider import rousi as rousi_module
|
|
from app.modules.indexer.spider.rousi import RousiSpider
|
|
from app.schemas import MediaType
|
|
|
|
|
|
class _FakeResponse:
|
|
"""构造 PeerGo 兼容 API 测试使用的最小响应对象。"""
|
|
|
|
def __init__(self, payload: dict, status_code: int = 200):
|
|
"""保存响应数据和状态码。"""
|
|
self._payload = payload
|
|
self.status_code = status_code
|
|
self.reason = "OK"
|
|
|
|
def json(self) -> dict:
|
|
"""返回预设 JSON 数据。"""
|
|
return self._payload
|
|
|
|
|
|
def _build_indexer(apikey: str = "rousi-secret", proxy: bool = False) -> dict:
|
|
"""构造 Rousi Pro API Spider 所需的最小站点配置。"""
|
|
return {
|
|
"id": "rousipro",
|
|
"name": "Rousi Pro",
|
|
"domain": "https://rousi.pro/",
|
|
"apikey": apikey,
|
|
"ua": "MoviePilot-Test",
|
|
"proxy": proxy,
|
|
}
|
|
|
|
|
|
def _build_user_parser() -> RousiSiteUserInfo:
|
|
"""构造无需真实网络请求的 Rousi 用户数据解析器。"""
|
|
return RousiSiteUserInfo(
|
|
site_name="Rousi Pro",
|
|
url="https://rousi.pro/",
|
|
site_cookie="",
|
|
apikey="rousi-secret",
|
|
token=None,
|
|
ua="MoviePilot-Test",
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def rousi_spider(monkeypatch):
|
|
"""构造不依赖真实数据库配置的 RousiSpider。"""
|
|
monkeypatch.setattr(rousi_module, "get_configured_system_config", lambda: None)
|
|
return RousiSpider(_build_indexer())
|
|
|
|
|
|
def test_music_search_uses_music_category(rousi_spider):
|
|
"""音乐搜索应提交 Rousi Pro 的 music 分类参数。"""
|
|
params = rousi_spider._RousiSpider__get_params("张学友", MediaType.MUSIC, None, 0)
|
|
|
|
assert params["category"] == "music"
|
|
|
|
|
|
def test_user_selected_music_category_maps_to_api_name(rousi_spider):
|
|
"""用户选择音乐分类 ID 时应映射为 API 的 music 分类名。"""
|
|
params = rousi_spider._RousiSpider__get_params("张学友", None, "5", 0)
|
|
|
|
assert params["category"] == "music"
|
|
|
|
|
|
def test_parse_result_marks_music_torrents(rousi_spider):
|
|
"""music 分类种子应标记为音乐媒体类型。"""
|
|
torrents = rousi_spider._RousiSpider__parse_result([
|
|
{"id": 1, "uuid": "u1", "title": "张学友 - 他在那里 FLAC", "category": "music"},
|
|
{"id": 2, "uuid": "u2", "title": "流浪地球", "category": "movie"},
|
|
{"id": 3, "uuid": "u3", "title": "剧集 S01", "category": {"slug": "tv"}},
|
|
])
|
|
|
|
assert [torrent["category"] for torrent in torrents] == [
|
|
MediaType.MUSIC.value,
|
|
MediaType.MOVIE.value,
|
|
MediaType.TV.value,
|
|
]
|
|
|
|
|
|
def test_search_uses_peergo_personal_api_key_contract(monkeypatch):
|
|
"""搜索应使用个人 API Key 兼容响应并生成短时下载地址换取请求。"""
|
|
captured = {}
|
|
payload = {
|
|
"code": 0,
|
|
"message": "success",
|
|
"data": {
|
|
"page": 1,
|
|
"page_size": 100,
|
|
"total": 1,
|
|
"total_pages": 1,
|
|
"torrents": [{
|
|
"category": "movie",
|
|
"category_name": "电影",
|
|
"created_at": "2026-06-09T12:04:15.184279Z",
|
|
"downloads": 21,
|
|
"id": 8461,
|
|
"leechers": 6,
|
|
"promotion": {
|
|
"down_multiplier": 0,
|
|
"is_active": True,
|
|
"until": "2026-09-21T16:36:31.008958Z",
|
|
"up_multiplier": 2,
|
|
},
|
|
"seeders": 31,
|
|
"size": 190438784128,
|
|
"subtitle": "电影中文副标题",
|
|
"title": "Movie.2026.2160p.UHD.BluRay",
|
|
"uuid": "8461",
|
|
}],
|
|
},
|
|
}
|
|
|
|
def fake_get_res(request, url: str, params: dict = None, **_kwargs):
|
|
"""记录搜索请求并回放 PeerGo MoviePilot 兼容响应。"""
|
|
captured.update({"url": url, "params": params, "headers": request._headers})
|
|
return _FakeResponse(payload)
|
|
|
|
monkeypatch.setattr(rousi_module, "get_configured_system_config", lambda: None)
|
|
monkeypatch.setattr(rousi_module, "get_runtime_setting", lambda _key: {"https": "proxy"})
|
|
monkeypatch.setattr(rousi_module.RequestUtils, "get_res", fake_get_res)
|
|
|
|
error, torrents = RousiSpider(_build_indexer(proxy=True)).search(
|
|
keyword="Movie",
|
|
mtype=MediaType.MOVIE,
|
|
page=0,
|
|
)
|
|
|
|
assert not error
|
|
assert captured == {
|
|
"url": "https://rousi.pro/api/v1/torrents",
|
|
"params": {
|
|
"page": 1,
|
|
"page_size": 100,
|
|
"keyword": "Movie",
|
|
"category": "movie",
|
|
},
|
|
"headers": {
|
|
"Authorization": "Bearer rousi-secret",
|
|
"Accept": "application/json",
|
|
},
|
|
}
|
|
assert len(torrents) == 1
|
|
assert torrents[0]["title"] == "Movie.2026.2160p.UHD.BluRay"
|
|
assert torrents[0]["page_url"] == "https://rousi.pro/torrents/8461"
|
|
assert torrents[0]["downloadvolumefactor"] == 0
|
|
assert torrents[0]["uploadvolumefactor"] == 2
|
|
assert torrents[0]["category"] == MediaType.MOVIE.value
|
|
|
|
encoded_config, detail_url = torrents[0]["enclosure"].split("]", 1)
|
|
request_config = json.loads(base64.b64decode(encoded_config[1:]).decode("utf-8"))
|
|
assert detail_url == "https://rousi.pro/api/v1/torrents/8461"
|
|
assert request_config == {
|
|
"method": "get",
|
|
"cookie": False,
|
|
"header": {
|
|
"Authorization": "Bearer rousi-secret",
|
|
"Accept": "application/json",
|
|
},
|
|
"proxy": True,
|
|
"result": "data.download_url",
|
|
}
|
|
|
|
|
|
def test_search_rejects_missing_personal_api_key(monkeypatch):
|
|
"""未配置个人 API Key 时不得向 PeerGo 发起搜索请求。"""
|
|
monkeypatch.setattr(rousi_module, "get_configured_system_config", lambda: None)
|
|
spider = RousiSpider(_build_indexer(apikey=""))
|
|
|
|
error, torrents = spider.search(keyword="Movie")
|
|
|
|
assert error
|
|
assert torrents == []
|
|
|
|
|
|
def test_user_parser_reads_peergo_profile_with_personal_api_key(monkeypatch):
|
|
"""用户数据解析应通过个人 API Key 读取 PeerGo 兼容资料结构。"""
|
|
captured = {}
|
|
profile = {
|
|
"code": 0,
|
|
"message": "success",
|
|
"data": {
|
|
"id": 619,
|
|
"username": "jxxghp",
|
|
"level_text": "Lv.1",
|
|
"registered_at": "2026-01-05T19:50:59.012Z",
|
|
"uploaded": 1099511627776,
|
|
"downloaded": 536870912000,
|
|
"ratio": 2.048,
|
|
"karma": 1079960,
|
|
"seeding_leeching_data": {
|
|
"seeding_count": 8,
|
|
"seeding_size": 2147483648000,
|
|
"leeching_count": 1,
|
|
"leeching_size": 10737418240,
|
|
},
|
|
},
|
|
}
|
|
|
|
def fake_get_page_content(_self, url: str, headers: dict = None, **_kwargs):
|
|
"""记录用户资料请求并回放 PeerGo 兼容响应。"""
|
|
captured.update({"url": url, "headers": headers})
|
|
return json.dumps(profile)
|
|
|
|
monkeypatch.setattr(RousiSiteUserInfo, "_get_page_content", fake_get_page_content)
|
|
parser = RousiSiteUserInfo(
|
|
site_name="Rousi Pro",
|
|
url="https://rousi.pro/",
|
|
site_cookie="",
|
|
apikey="rousi-secret",
|
|
token=None,
|
|
ua="MoviePilot-Test",
|
|
)
|
|
|
|
parser.parse()
|
|
|
|
assert captured["url"] == "https://rousi.pro/api/v1/profile"
|
|
assert captured["headers"]["Authorization"] == "Bearer rousi-secret"
|
|
assert parser.userid == 619
|
|
assert parser.username == "jxxghp"
|
|
assert parser.user_level == "Lv.1"
|
|
assert parser.join_at == "2026-01-05 19:50:59"
|
|
assert parser.upload == 1099511627776
|
|
assert parser.download == 536870912000
|
|
assert parser.ratio == 2.05
|
|
assert parser.bonus == 1079960
|
|
assert parser.seeding == 8
|
|
assert parser.seeding_size == 2147483648000
|
|
assert parser.leeching == 1
|
|
assert parser.leeching_size == 10737418240
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("payload", "expected_error"),
|
|
[
|
|
([], "用户数据响应结构无效"),
|
|
({"code": 1, "message": 619}, "619"),
|
|
({"code": 1, "message": None}, "未知错误"),
|
|
],
|
|
)
|
|
def test_user_parser_normalizes_profile_errors(
|
|
payload: object,
|
|
expected_error: str,
|
|
) -> None:
|
|
"""无效资料响应必须给调用方稳定的字符串错误消息。"""
|
|
parser = _build_user_parser()
|
|
|
|
parser._parse_user_base_info(json.dumps(payload))
|
|
|
|
assert parser.err_msg == expected_error
|
|
|
|
|
|
@pytest.mark.parametrize("registered_at", [None, 619, {"unexpected": "value"}])
|
|
def test_user_parser_ignores_non_string_registration_time(
|
|
registered_at: object,
|
|
) -> None:
|
|
"""非字符串注册时间不得被强转为看似有效的日期输入。"""
|
|
parser = _build_user_parser()
|
|
payload = {
|
|
"code": 0,
|
|
"data": {
|
|
"registered_at": registered_at,
|
|
},
|
|
}
|
|
|
|
parser._parse_user_base_info(json.dumps(payload))
|
|
|
|
assert parser.join_at is None
|
|
|
|
|
|
def test_site_connectivity_uses_peergo_personal_api_key(monkeypatch):
|
|
"""Rousi 连接测试应以 Bearer 个人 API Key 请求兼容资料接口。"""
|
|
captured = {}
|
|
|
|
def fake_get_res(request, url: str, **_kwargs):
|
|
"""记录连接测试请求并返回有效用户资料。"""
|
|
captured.update({"url": url, "headers": request._headers})
|
|
return _FakeResponse({"code": 0, "message": "success", "data": {"id": 619}})
|
|
|
|
monkeypatch.setattr("app.chain.site.RequestUtils.get_res", fake_get_res)
|
|
site = SimpleNamespace(
|
|
url="https://rousi.pro/",
|
|
apikey="rousi-secret",
|
|
proxy=0,
|
|
timeout=15,
|
|
)
|
|
chain = object.__new__(SiteChain)
|
|
chain.runtime_config = SimpleNamespace(proxy=None)
|
|
|
|
state, message = chain._SiteChain__rousi_test(site)
|
|
|
|
assert state
|
|
assert message == "连接成功"
|
|
assert captured["url"] == "https://rousi.pro/api/v1/profile"
|
|
assert captured["headers"]["Authorization"] == "Bearer rousi-secret"
|
|
|
|
|
|
def test_site_connectivity_rejects_missing_personal_api_key():
|
|
"""Rousi 连接测试应在请求前报告个人 API Key 缺失。"""
|
|
site = SimpleNamespace(url="https://rousi.pro/", apikey="", proxy=0, timeout=15)
|
|
chain = object.__new__(SiteChain)
|
|
|
|
state, message = chain._SiteChain__rousi_test(site)
|
|
|
|
assert not state
|
|
assert message == "未配置个人 API Key"
|