Files
MoviePilot/tests/test_rousi_spider.py
T

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"