mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-09 15:34:31 +08:00
476 lines
16 KiB
Python
476 lines
16 KiB
Python
import asyncio
|
|
import base64
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.chain.download import DownloadChain
|
|
from app.chain.site import SiteChain
|
|
from app.core.config import settings
|
|
from app.core.context import TorrentInfo
|
|
from app.db.message_oper import MessageOper
|
|
from app.modules.indexer import IndexerModule
|
|
from app.modules.indexer.parser.sunnypt import SunnyPTSiteUserInfo
|
|
from app.modules.indexer.spider.sunnypt import SunnyPTSpider
|
|
from app.schemas import MediaType, NotificationType
|
|
|
|
|
|
class _FakeResponse:
|
|
"""构造站点 API 测试使用的最小响应对象。"""
|
|
|
|
def __init__(self, payload: dict, status_code: int = 200):
|
|
"""保存响应数据和状态码。"""
|
|
self._payload = payload
|
|
self.status_code = status_code
|
|
self.reason = "OK"
|
|
|
|
def __bool__(self) -> bool:
|
|
"""按 HTTP 成功状态模拟 requests.Response 的布尔值。"""
|
|
return self.status_code < 400
|
|
|
|
def json(self) -> dict:
|
|
"""返回预设 JSON 数据。"""
|
|
return self._payload
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_sunnypt_category_cache():
|
|
"""在用例前后清理 SunnyPT 分类缓存,避免进程级状态互相污染。"""
|
|
SunnyPTSpider._category_cache.clear()
|
|
yield
|
|
SunnyPTSpider._category_cache.clear()
|
|
|
|
|
|
def _build_indexer() -> dict:
|
|
"""构造 SunnyPT API Spider 所需的最小站点配置。"""
|
|
return {
|
|
"id": "sunnypt",
|
|
"name": "Sunny",
|
|
"domain": "https://sunnypt.top/",
|
|
"api_url": "https://api.sunnypt.top/api/v1/mp",
|
|
"apikey": "sunny-secret",
|
|
"ua": "MoviePilot-Test",
|
|
"proxy": False,
|
|
"category": {
|
|
"movie": [{"id": 401}, {"id": 404}, {"id": 405}],
|
|
"tv": [{"id": 402}, {"id": 403}, {"id": 404}, {"id": 405}],
|
|
},
|
|
}
|
|
|
|
|
|
def _category_response() -> _FakeResponse:
|
|
"""构造 SunnyPT 分类接口响应。"""
|
|
return _FakeResponse({
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": [
|
|
{"id": 401, "name": "电影", "media_types": ["movie"]},
|
|
{"id": 402, "name": "电视剧", "media_types": ["tv"]},
|
|
{"id": 404, "name": "纪录片", "media_types": ["movie", "tv"]},
|
|
],
|
|
})
|
|
|
|
|
|
def _torrent_response() -> _FakeResponse:
|
|
"""构造 SunnyPT 种子搜索接口响应。"""
|
|
return _FakeResponse({
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": {
|
|
"items": [{
|
|
"id": 123,
|
|
"title": "Movie.2026.2160p.WEB-DL.H.265-GROUP",
|
|
"subtitle": "电影中文副标题",
|
|
"media_type": "movie",
|
|
"size": 21474836480,
|
|
"created_at": "2026-07-21T14:00:00+08:00",
|
|
"seeders": 15,
|
|
"leechers": 2,
|
|
"completed": 30,
|
|
"imdb_id": "tt1234567",
|
|
"tags": ["中字", "HDR"],
|
|
"hit_and_run": True,
|
|
"promotion": {
|
|
"is_active": True,
|
|
"up_multiplier": 2.0,
|
|
"down_multiplier": 0.0,
|
|
"until": "2026-07-23T14:00:00+08:00",
|
|
},
|
|
"details_url": "https://sunnypt.top/torrent/123",
|
|
}],
|
|
},
|
|
})
|
|
|
|
|
|
def test_sunnypt_search_maps_api_fields_and_caches_categories(monkeypatch):
|
|
"""SunnyPT 搜索应映射标准字段、编码下载请求并复用分类缓存。"""
|
|
calls = []
|
|
|
|
def fake_get_res(request, url: str, params: dict = None, **_kwargs):
|
|
"""按请求路径回放分类和种子响应。"""
|
|
calls.append((url, params, request._headers))
|
|
if url.endswith("/categories"):
|
|
return _category_response()
|
|
return _torrent_response()
|
|
|
|
monkeypatch.setattr(
|
|
"app.modules.indexer.spider.sunnypt.RequestUtils.get_res",
|
|
fake_get_res,
|
|
)
|
|
spider = SunnyPTSpider(_build_indexer())
|
|
|
|
error, torrents = spider.search(
|
|
keyword="tt1234567",
|
|
mtype=MediaType.MOVIE,
|
|
page=0,
|
|
)
|
|
second_error, _ = SunnyPTSpider(_build_indexer()).search(
|
|
keyword="Movie",
|
|
mtype=MediaType.MOVIE,
|
|
page=1,
|
|
)
|
|
|
|
assert not error
|
|
assert not second_error
|
|
assert [call[0] for call in calls].count(
|
|
"https://api.sunnypt.top/api/v1/mp/categories"
|
|
) == 1
|
|
search_url, search_params, search_headers = calls[1]
|
|
assert search_url == "https://api.sunnypt.top/api/v1/mp/torrents"
|
|
assert search_params == {
|
|
"page": 1,
|
|
"page_size": 100,
|
|
"sort": "created_at",
|
|
"order": "desc",
|
|
"keyword": "tt1234567",
|
|
"media_type": "movie",
|
|
"categories": "401,404",
|
|
}
|
|
assert search_headers["X-API-Key"] == "sunny-secret"
|
|
assert torrents == [{
|
|
"title": "Movie.2026.2160p.WEB-DL.H.265-GROUP",
|
|
"description": "电影中文副标题",
|
|
"enclosure": torrents[0]["enclosure"],
|
|
"pubdate": "2026-07-21 14:00:00",
|
|
"size": 21474836480,
|
|
"seeders": 15,
|
|
"peers": 2,
|
|
"grabs": 30,
|
|
"downloadvolumefactor": 0.0,
|
|
"uploadvolumefactor": 2.0,
|
|
"freedate": "2026-07-23 14:00:00",
|
|
"page_url": "https://sunnypt.top/torrent/123",
|
|
"imdbid": "tt1234567",
|
|
"labels": ["中字", "HDR"],
|
|
"hit_and_run": True,
|
|
"category": MediaType.MOVIE.value,
|
|
}]
|
|
|
|
encoded_config, token_url = torrents[0]["enclosure"].split("]", 1)
|
|
request_config = json.loads(base64.b64decode(encoded_config[1:]).decode("utf-8"))
|
|
assert token_url == "https://api.sunnypt.top/api/v1/mp/torrents/123/download-token"
|
|
assert request_config == {
|
|
"method": "post",
|
|
"cookie": False,
|
|
"header": {
|
|
"X-API-Key": "sunny-secret",
|
|
"Accept": "application/json",
|
|
},
|
|
"proxy": False,
|
|
"result": "data.download_url",
|
|
"result_base_url": "https://api.sunnypt.top/api/v1/mp",
|
|
}
|
|
|
|
|
|
def test_sunnypt_async_search_uses_api_contract(monkeypatch):
|
|
"""SunnyPT 异步搜索应使用同一套 GET 参数和响应映射。"""
|
|
calls = []
|
|
|
|
async def fake_get_res(request, url: str, params: dict = None, **_kwargs):
|
|
"""异步回放分类和种子响应。"""
|
|
calls.append((url, params, request._headers))
|
|
if url.endswith("/categories"):
|
|
return _category_response()
|
|
return _torrent_response()
|
|
|
|
monkeypatch.setattr(
|
|
"app.modules.indexer.spider.sunnypt.AsyncRequestUtils.get_res",
|
|
fake_get_res,
|
|
)
|
|
|
|
error, torrents = asyncio.run(
|
|
SunnyPTSpider(_build_indexer()).async_search(
|
|
keyword="Movie",
|
|
mtype=MediaType.TV,
|
|
cat="402,404",
|
|
page=2,
|
|
)
|
|
)
|
|
|
|
assert not error
|
|
assert len(torrents) == 1
|
|
assert len(calls) == 1
|
|
assert calls[0][1]["page"] == 3
|
|
assert calls[0][1]["media_type"] == "tv"
|
|
assert calls[0][1]["categories"] == "402,404"
|
|
assert calls[0][2]["X-API-Key"] == "sunny-secret"
|
|
|
|
|
|
def test_sunnypt_user_parser_reads_profile_and_messages_without_marking_read(monkeypatch):
|
|
"""SunnyPT 用户解析器应读取统计和未读消息,但不能在解析阶段标记已读。"""
|
|
requested_urls = []
|
|
profile = {
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": {
|
|
"id": 1001,
|
|
"username": "sunny",
|
|
"level": "Power User",
|
|
"registered_at": "2025-01-01T12:00:00+08:00",
|
|
"uploaded": 1099511627776,
|
|
"downloaded": 536870912000,
|
|
"ratio": 2.048,
|
|
"bonus": 12345.6,
|
|
"seeding_count": 30,
|
|
"seeding_size": 2147483648000,
|
|
"leeching_count": 1,
|
|
"leeching_size": 10737418240,
|
|
"unread_messages": 1,
|
|
},
|
|
}
|
|
messages = {
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": {
|
|
"items": [{
|
|
"id": 9001,
|
|
"title": "种子审核通过",
|
|
"content": "你发布的种子已审核通过。",
|
|
"created_at": "2026-07-21T16:30:00+08:00",
|
|
"unread": True,
|
|
}],
|
|
"unread_count": 1,
|
|
"has_more": False,
|
|
},
|
|
}
|
|
|
|
def fake_get_page_content(_self, url: str, **_kwargs):
|
|
"""按请求地址回放用户信息和消息列表。"""
|
|
requested_urls.append(url)
|
|
return json.dumps(messages if "/messages" in url else profile)
|
|
|
|
monkeypatch.setattr(SunnyPTSiteUserInfo, "_get_page_content", fake_get_page_content)
|
|
monkeypatch.setattr(settings, "SITE_MESSAGE", True)
|
|
parser = SunnyPTSiteUserInfo(
|
|
site_name="Sunny",
|
|
url="https://sunnypt.top/",
|
|
site_cookie="",
|
|
apikey="sunny-secret",
|
|
token=None,
|
|
api_url="https://api.sunnypt.top/api/v1/mp",
|
|
)
|
|
|
|
parser.parse()
|
|
|
|
assert parser.userid == 1001
|
|
assert parser.username == "sunny"
|
|
assert parser.user_level == "Power User"
|
|
assert parser.join_at == "2025-01-01 12:00:00"
|
|
assert parser.upload == 1099511627776
|
|
assert parser.download == 536870912000
|
|
assert parser.ratio == 2.048
|
|
assert parser.bonus == 12345.6
|
|
assert parser.seeding == 30
|
|
assert parser.seeding_size == 2147483648000
|
|
assert parser.leeching == 1
|
|
assert parser.leeching_size == 10737418240
|
|
assert parser.message_unread == 1
|
|
assert parser.message_unread_contents == [
|
|
(
|
|
"种子审核通过",
|
|
"2026-07-21 16:30:00",
|
|
"你发布的种子已审核通过。",
|
|
"sunnypt-message:9001",
|
|
)
|
|
]
|
|
assert not any(url.endswith("/read") or url.endswith("/read-all") for url in requested_urls)
|
|
|
|
|
|
def test_site_messages_are_deduplicated_by_persisted_source(monkeypatch):
|
|
"""站点消息应使用解析器保留的消息 ID 来源标识做持久化去重。"""
|
|
duplicate_source = "sunnypt-message:9001-dedup-test"
|
|
MessageOper().add(
|
|
source=duplicate_source,
|
|
mtype=NotificationType.SiteMessage,
|
|
title="existing",
|
|
text="existing",
|
|
)
|
|
sent_messages = []
|
|
chain = object.__new__(SiteChain)
|
|
chain.messageoper = MessageOper()
|
|
monkeypatch.setattr(chain, "post_message", sent_messages.append)
|
|
userdata = SimpleNamespace(
|
|
message_unread=2,
|
|
message_unread_contents=[
|
|
("重复消息", "2026-07-21 16:30:00", "旧内容", duplicate_source),
|
|
("新消息", "2026-07-22 16:30:00", "新内容", "sunnypt-message:9002-dedup-test"),
|
|
],
|
|
)
|
|
|
|
chain._post_site_messages(
|
|
site={"name": "Sunny", "url": "https://sunnypt.top/"},
|
|
userdata=userdata,
|
|
)
|
|
|
|
assert len(sent_messages) == 1
|
|
assert sent_messages[0].source == "sunnypt-message:9002-dedup-test"
|
|
assert sent_messages[0].title == "【站点 Sunny 消息】"
|
|
|
|
|
|
def test_indexer_module_dispatches_sunnypt_search(monkeypatch):
|
|
"""IndexerModule 应把 SunnyPT 同步搜索参数交给专用 API Spider。"""
|
|
captured = {}
|
|
|
|
def fake_search(_self, keyword, mtype, cat, page):
|
|
"""记录 IndexerModule 传给 SunnyPT Spider 的搜索参数。"""
|
|
captured.update({
|
|
"keyword": keyword,
|
|
"mtype": mtype,
|
|
"cat": cat,
|
|
"page": page,
|
|
})
|
|
return False, [{"title": "Movie.2026", "enclosure": "https://example.com/1.torrent"}]
|
|
|
|
monkeypatch.setattr(SunnyPTSpider, "search", fake_search)
|
|
monkeypatch.setattr(
|
|
IndexerModule,
|
|
"_IndexerModule__search_check",
|
|
staticmethod(lambda _site, _keyword=None: True),
|
|
)
|
|
monkeypatch.setattr(
|
|
IndexerModule,
|
|
"_IndexerModule__indexer_statistic",
|
|
staticmethod(lambda **_kwargs: None),
|
|
)
|
|
site = {
|
|
**_build_indexer(),
|
|
"parser": "SunnyPT",
|
|
"pri": 1,
|
|
}
|
|
|
|
torrents = object.__new__(IndexerModule).search_torrents(
|
|
site=site,
|
|
keyword="Movie",
|
|
mtype=MediaType.MOVIE,
|
|
cat="401",
|
|
page=2,
|
|
)
|
|
|
|
assert captured == {
|
|
"keyword": "Movie",
|
|
"mtype": MediaType.MOVIE,
|
|
"cat": "401",
|
|
"page": 2,
|
|
}
|
|
assert len(torrents) == 1
|
|
assert torrents[0].title == "Movie.2026"
|
|
|
|
|
|
def test_sunnypt_site_test_uses_profile_api(monkeypatch):
|
|
"""SunnyPT 连接测试应使用 Build 配置的 profile API 和 X-API-Key。"""
|
|
captured = {}
|
|
|
|
def fake_get_indexer(domain: str):
|
|
"""返回包含独立 API 地址的 SunnyPT 索引配置。"""
|
|
assert domain == "sunnypt.top"
|
|
return {"api_url": "https://api.sunnypt.top/api/v1/mp"}
|
|
|
|
def fake_sites_helper():
|
|
"""构造不触发动态资源保护元类的站点帮助器替身。"""
|
|
return SimpleNamespace(get_indexer=fake_get_indexer)
|
|
|
|
def fake_get_res(request, url: str, **_kwargs):
|
|
"""记录站点连接测试请求并返回有效用户资料。"""
|
|
captured.update({"url": url, "headers": request._headers})
|
|
return _FakeResponse({
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": {"download_allowed": True},
|
|
})
|
|
|
|
monkeypatch.setattr("app.chain.site.SitesHelper", fake_sites_helper)
|
|
monkeypatch.setattr("app.chain.site.RequestUtils.get_res", fake_get_res)
|
|
site = SimpleNamespace(
|
|
domain="sunnypt.top",
|
|
ua="MoviePilot-Test",
|
|
apikey="sunny-secret",
|
|
proxy=0,
|
|
timeout=15,
|
|
)
|
|
|
|
state, message = SiteChain._SiteChain__sunnypt_test(site)
|
|
|
|
assert state
|
|
assert message == "连接成功"
|
|
assert captured["url"] == "https://api.sunnypt.top/api/v1/mp/profile"
|
|
assert captured["headers"]["X-API-Key"] == "sunny-secret"
|
|
assert "Authorization" not in captured["headers"]
|
|
|
|
|
|
def test_indirect_download_does_not_log_or_cache_temporary_url(monkeypatch):
|
|
"""两段式下载不得记录或缓存包含短时凭证的真实下载地址。"""
|
|
captured = {}
|
|
log_messages = []
|
|
|
|
def fake_post_res(_request, url: str, params: dict = None, **_kwargs):
|
|
"""回放 download-token 接口返回的短时下载地址。"""
|
|
captured["token_url"] = url
|
|
captured["token_params"] = params
|
|
return _FakeResponse({
|
|
"code": 0,
|
|
"msg": "ok",
|
|
"data": {
|
|
"download_url": "https://sunnypt.top/api/v1/mp/download/temporary-credential",
|
|
},
|
|
})
|
|
|
|
def fake_download_torrent(_helper, **kwargs):
|
|
"""记录 TorrentHelper 的失败缓存开关并返回有效种子内容。"""
|
|
captured.update(kwargs)
|
|
return None, b"torrent-content", "Movie", ["Movie.mkv"], ""
|
|
|
|
def capture_log(message: str):
|
|
"""收集下载链日志以校验敏感地址不会泄露。"""
|
|
log_messages.append(message)
|
|
|
|
monkeypatch.setattr("app.chain.download.RequestUtils.post_res", fake_post_res)
|
|
monkeypatch.setattr(
|
|
"app.chain.download.TorrentHelper.download_torrent",
|
|
fake_download_torrent,
|
|
)
|
|
monkeypatch.setattr(
|
|
"app.chain.download.logger",
|
|
SimpleNamespace(info=capture_log, error=capture_log),
|
|
)
|
|
enclosure = SunnyPTSpider(_build_indexer())._build_download_url(123)
|
|
torrent = TorrentInfo(
|
|
title="Movie.2026",
|
|
enclosure=enclosure,
|
|
site_ua="MoviePilot-Test",
|
|
site_proxy=False,
|
|
)
|
|
|
|
content, folder, files = object.__new__(DownloadChain).download_torrent(torrent)
|
|
|
|
assert content == b"torrent-content"
|
|
assert folder == "Movie"
|
|
assert files == ["Movie.mkv"]
|
|
assert captured["cache_invalid"] is False
|
|
assert captured["cookie"] is None
|
|
assert captured["url"] == (
|
|
"https://api.sunnypt.top/api/v1/mp/download/temporary-credential"
|
|
)
|
|
assert not any("sunny-secret" in message for message in log_messages)
|
|
assert not any("temporary-credential" in message for message in log_messages)
|