Files
MoviePilot/tests/test_search_manual.py
2026-11-01 09:51:24 +08:00

764 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Web 手动分页:后端缓存页摘要并恢复过期比较依据,页号与重试由客户端持有。
"""
import asyncio
import threading
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.application.site.observation import report_site_search_outcome, report_site_search_page
from app.chain.search import manual as manual_module
from app.chain.search.facade import SearchChain
from app.chain.search.manual import SearchManualOwner, decode_source, encode_source
from app.domain.context import MediaInfo, TorrentInfo
from app.schemas.types import MediaSource, MediaType
@pytest.fixture
def page_clock(monkeypatch):
"""
使用可控时钟隔离页摘要缓存,验证失效后的上一页补取。
"""
from app.runtime import cache as cache_module
clock = [0.0]
original = cache_module.MemoryTLRUCache.__init__
def initialize(cache, *args, **kwargs):
"""
让测试缓存的过期判断统一使用可控时钟。
"""
original(cache, *args, **kwargs, timer=lambda: clock[0])
monkeypatch.setattr(cache_module.MemoryTLRUCache, "__init__", initialize)
monkeypatch.setattr(cache_module.MemoryBackend, "_region_caches", {})
monkeypatch.setattr(manual_module, "Cache", cache_module.MemoryBackend)
return clock
@pytest.fixture
def manual_owner(monkeypatch, page_clock):
"""
构造离线搜索来源和保存边界,记录页请求并模拟失败。
"""
owner = SearchChain()
sites = [{"id": 1, "name": "A"}, {"id": 2, "name": "B"}]
monkeypatch.setattr(owner, "_sync_indexers", lambda ids: [site for site in sites if not ids or site["id"] in ids])
monkeypatch.setattr(owner, "search_plugin_torrents", lambda **_params: [])
monkeypatch.setattr(owner, "get_search_page_size", lambda **_params: 100)
saved = []
monkeypatch.setattr(owner, "save_last_search_params", lambda **params: saved.append(params))
async def save_results(contexts):
"""
记录首次搜索的保存行为,避免写入真实缓存。
"""
saved.append(len(contexts))
monkeypatch.setattr(owner, "_async_save_results", save_results)
calls = []
failures = set()
def request(**params):
"""
按来源和页号构造结果,同时报告真实搜索观察字段。
"""
site, page = params["site"]["id"], params["page"]
calls.append((site, params["keyword"], page))
failed = (site, page) in failures
report_site_search_outcome(attempted=True, outcome="error" if failed else "success", error="timeout" if failed else None)
report_site_search_page(raw_count=1, has_more=page < 2)
return [] if failed else [TorrentInfo(site=site, description=f"{site}-{page}", title=f"Show S01E{page+1:02d}",
enclosure=f"https://site.example/download?id={page}")]
monkeypatch.setattr(owner, "search_site_torrents", request)
return owner, calls, (failures, saved)
async def events(iterator):
"""
消费完整事件流,供最终页结果和来源状态断言使用。
"""
return [item async for item in iterator]
def site_facts(result):
"""
从最终状态中提取支持独立翻页的站点来源。
"""
return [item for item in result[-1]["sources"] if decode_source(item["source"])[0] != "plugin"]
@pytest.fixture
def blocked_site(manual_owner, monkeypatch):
"""
阻塞首个站点,其他站点即时完成,用同步信号验证并发和取消收口。
"""
from app.runtime import tasks as tasks_module
owner, _, _ = manual_owner
monkeypatch.setattr(owner, "_runtime_config", replace(owner.runtime_config, search_threadpool_size=2))
monkeypatch.setattr(owner, "_runtime_config_provider", None)
registry = tasks_module.TaskRegistry()
monkeypatch.setattr(tasks_module, "_runtime_registry", registry)
started, release, finished = threading.Event(), threading.Event(), threading.Event()
original = owner.search_site_torrents
def request(**params):
"""
让排序靠前的站点等待释放,证明快站点不受它阻塞。
"""
if params["site"]["id"] != 1:
return original(**params)
started.set()
try:
assert release.wait(5), "测试未释放慢站点"
return original(**params)
finally:
finished.set()
monkeypatch.setattr(owner, "search_site_torrents", request)
yield started, release, finished, registry
release.set()
@pytest.mark.asyncio
async def test_media_search_mock_overrides_cached_instance_method(manual_owner, monkeypatch):
"""
单例缓存了实例级原方法时,mock 仍覆盖该方法并隔离真实网络请求。
"""
recognizer = manual_module.MediaChain()
monkeypatch.setattr(recognizer, "async_recognize_media", recognizer.async_recognize_media)
media = MediaInfo(media_source=MediaSource.TMDB, media_id="1", tmdb_id=1, type=MediaType.TV,
title="Show", original_title="Show", names=["Show"])
recognize = AsyncMock(return_value=media)
monkeypatch.setattr(recognizer, "async_recognize_media", recognize)
monkeypatch.setattr(recognizer, "async_supplement_media_info", AsyncMock(return_value=media))
owner, _, _ = manual_owner
result = await events(SearchManualOwner.events(
owner, params={"media_source": MediaSource.TMDB, "media_id": "1", "mtype": MediaType.TV},
))
recognize.assert_awaited_once()
assert result[-1]["type"] == "done"
assert result[-2]["items"]
@pytest.mark.asyncio
@pytest.mark.parametrize("media_search", [False, True])
@pytest.mark.parametrize("transport", [False, True])
async def test_fast_site_preview_arrives_before_slow_site_and_final_page(
manual_owner, blocked_site, monkeypatch, media_search, transport,
):
"""
标题和精确搜索均先展示快站点,最终过滤前不提交来源页号。
"""
owner, calls, (_, saved) = manual_owner
started, release, finished, registry = blocked_site
params = {"keyword": "Show"}
if media_search:
media = MediaInfo(media_source=MediaSource.TMDB, media_id="1", tmdb_id=1, type=MediaType.TV,
title="Show", original_title="Show", names=["Show"])
# 单例可能保留实例级方法,必须在实际实例上 mock,不能只覆盖类方法。
recognizer = manual_module.MediaChain()
monkeypatch.setattr(recognizer, "async_recognize_media", AsyncMock(return_value=media))
monkeypatch.setattr(recognizer, "async_supplement_media_info", AsyncMock(return_value=media))
params = {"media_source": MediaSource.TMDB, "media_id": "1", "mtype": MediaType.TV}
stream = SearchManualOwner.events(owner, params=params)
if transport:
from app.api.endpoints import search as search_endpoint
monkeypatch.setattr(search_endpoint, "_SSE_APPEND_FLUSH_INTERVAL", 0.01)
stream = search_endpoint._iter_batched_search_events(stream)
received = []
try:
async with asyncio.timeout(3):
while True:
event = await anext(stream)
received.append(event)
if event.get("items"):
break
assert started.is_set() and not finished.is_set()
assert event["type"] == "append" and event["stage"] == "searching"
assert [item["torrent_info"]["site"] for item in event["items"]] == [2]
assert "sources" not in event
assert not any(item["type"] in {"replace", "done"} for item in received)
assert saved == []
if media_search:
assert event["items"][0]["match_status"] == "candidate"
assert event["items"][0]["media_info_is_target"] is False
release.set()
received.extend(await events(stream))
finally:
release.set()
await stream.aclose()
assert sorted(calls) == [(1, "Show", 0), (2, "Show", 0)]
assert [item["type"] for item in received[-2:]] == ["replace", "done"]
assert len(received[-2]["items"]) == 2
assert [item["site_name"] for item in site_facts(received)] == ["A", "B"]
assert saved and not registry.records
@pytest.mark.asyncio
async def test_closing_preview_waits_for_inflight_workers_without_committing_page(manual_owner, blocked_site):
"""
离开搜索流时等待在途同步请求收尾,不写最终结果和翻页状态。
"""
owner, _, (_, saved) = manual_owner
started, release, finished, registry = blocked_site
stream = SearchManualOwner.events(owner, params={"keyword": "Show"})
try:
async with asyncio.timeout(3):
while not (await anext(stream)).get("items"):
pass
assert started.is_set() and not finished.is_set()
closing = asyncio.create_task(stream.aclose())
await asyncio.sleep(0)
assert not closing.done()
release.set()
await asyncio.wait_for(closing, timeout=3)
assert finished.is_set() and not registry.records
assert saved == []
finally:
release.set()
await stream.aclose()
@pytest.mark.asyncio
@pytest.mark.parametrize("requested,expected", [(None, None), (MediaType.UNKNOWN, None),
(MediaType.TV, MediaType.TV), (MediaType.MOVIE, MediaType.MOVIE)])
async def test_title_search_keeps_unrestricted_mteam_categories_and_explicit_type(manual_owner, monkeypatch, requested, expected):
"""
标题搜索保留馒头站点的不限分类请求,并按显式媒体类型筛选结果。
"""
from app.modules.indexer import IndexerModule
from app.modules.indexer.spider import mtorrent as mtorrent_module
from app.modules.indexer.spider.mtorrent import MTorrentSpider
owner, _, _ = manual_owner
monkeypatch.setattr(mtorrent_module, "get_configured_system_config", lambda: None)
spider = MTorrentSpider({"id": 1, "name": "M-Team", "domain": "https://mteam.example/", "apikey": "fake"})
requests = []
plugin_types = []
class FakeRequest:
"""
回放 M-Team 的分类与分页响应,不访问真实站点。
"""
def __init__(self, **_params):
"""
接受原传输层参数以保持索引器调用协议。
"""
pass
def post_res(self, _url=None, json=None, **_params):
"""
用分类和页号生成可重复的离线搜索响应。
"""
requests.append(json)
count = 1 if json["categories"] == MTorrentSpider._movie_category else 3
offset = (json["pageNumber"] - 1) * count
return SimpleNamespace(status_code=200, json=lambda: {"code": 0, "data": {
"data": [{"id": str(index), "name": f"Show S01E{index:02d}", "category": "403",
"size": "1024", "status": {}} for index in range(offset + 1, offset + count + 1)],
"pageNumber": json["pageNumber"], "pageSize": 100, "totalPages": 2,
"total": count * 2}})
monkeypatch.setattr(mtorrent_module, "RequestUtils", FakeRequest)
monkeypatch.setattr(owner, "_sync_indexers", lambda _ids: [{"id": 1, "name": "M-Team"}])
def request(**params):
assert params["mtype"] == expected
error, torrents = spider.search(keyword=params["keyword"], mtype=params["mtype"], page=params["page"])
assert not error
report_site_search_outcome(attempted=True, outcome="success")
return IndexerModule._IndexerModule__parse_result(params["site"], torrents, 0)
monkeypatch.setattr(owner, "search_site_torrents", request)
monkeypatch.setattr(owner, "search_plugin_torrents", lambda **params: plugin_types.append(params["mtype"]) or [])
params = {"keyword": "Show"}
if requested is not None:
params["mtype"] = requested
result = await events(SearchManualOwner.events(owner, params=params))
expected_categories = ([] if expected is None else MTorrentSpider._tv_category
if expected == MediaType.TV else MTorrentSpider._movie_category)
assert requests[0]["categories"] == expected_categories
assert len(result[-2]["items"]) == (1 if expected == MediaType.MOVIE else 3)
assert not any(source["error"] for source in result[-1]["sources"])
assert plugin_types == [expected]
source = result[-1]["sources"][0]
next_page = await events(SearchManualOwner.events(owner, params={
**params, "page": source["page"] + 1, "source": source["source"]}))
assert [request["pageNumber"] for request in requests] == [1, 2]
assert all(request["categories"] == expected_categories for request in requests)
assert len(next_page[-2]["items"]) == (1 if expected == MediaType.MOVIE else 3)
assert not any(source["error"] for source in next_page[-1]["sources"])
assert plugin_types == [expected]
@pytest.mark.asyncio
async def test_first_request_loads_page_zero_of_every_source_and_saves_last_search(manual_owner):
"""
首页并发请求各来源,但完成页事实仍保持来源顺序。
"""
owner, calls, (_, saved) = manual_owner
result = await events(SearchManualOwner.events(owner, params={"keyword": "Show"}))
assert sorted(calls) == [(1, "Show", 0), (2, "Show", 0)]
sources = site_facts(result)
assert [(item["site_name"], item["page"], item["can_continue"], item["error"]) for item in sources] == [
("A", 0, True, None), ("B", 0, True, None)]
assert decode_source(sources[0]["source"])[:2] == ("1", "Show")
assert set(sources[0]) == {"source", "site_name", "page", "can_continue", "error"}
plugin = next(item for item in result[-1]["sources"] if decode_source(item["source"])[0] == "plugin")
assert not plugin["can_continue"]
assert len(result[-2]["items"]) == 2
assert saved
@pytest.mark.asyncio
async def test_continuation_restores_previous_page_and_keeps_last_search(manual_owner):
"""
继续搜索恢复前页状态,保持上次实际搜索条件。
"""
owner, calls, (_, saved) = manual_owner
result = await events(SearchManualOwner.events(owner, params={
"keyword": "Show", "page": 1, "source": encode_source("2", "Show")}))
assert calls == [(2, "Show", 0), (2, "Show", 1)]
assert [item["torrent_info"]["description"] for item in result[-2]["items"]] == ["2-1"]
assert [(item["page"], item["can_continue"]) for item in result[-1]["sources"]] == [(1, True)]
# 续页不能把上次搜索缓存覆盖为单站单页。
assert saved == []
@pytest.mark.asyncio
@pytest.mark.parametrize("pageable", [True, False])
async def test_failed_source_keeps_its_page_for_retry_without_hiding_other_sources(manual_owner, monkeypatch, pageable):
"""
来源失败不影响其他来源,重试只请求原失败页。
"""
owner, calls, (failures, _) = manual_owner
monkeypatch.setattr(owner, "get_search_page_size", lambda **_params: 100 if pageable else None)
failures.add((1, 0))
result = await events(SearchManualOwner.events(owner, params={"keyword": "Show"}))
first, second = site_facts(result)
assert (first["error"], first["page"], first["can_continue"]) == ("timeout", 0, True)
assert (second["error"], second["page"], second["can_continue"]) == (None, 0, pageable)
assert sorted(calls) == [(1, "Show", 0), (2, "Show", 0)]
failures.clear()
retried = await events(SearchManualOwner.events(owner, params={
"keyword": "Show", "page": first["page"], "source": first["source"]}))
assert sorted(calls) == [(1, "Show", 0), (1, "Show", 0), (2, "Show", 0)]
assert (site_facts(retried)[0]["error"], site_facts(retried)[0]["can_continue"]) == (None, pageable)
@pytest.mark.asyncio
async def test_alias_fallback_only_on_first_page_and_source_keeps_actual_keyword(manual_owner, monkeypatch):
"""
别名回退仅发生在首页,后续分页使用最终命中的关键词。
"""
owner, calls, (failures, _) = manual_owner
monkeypatch.setattr(owner, "_prepare_params", lambda **_params: (None, ["AliasA", "AliasB"]))
failures.update({(1, 0), (2, 0)})
await events(SearchManualOwner.events(owner, params={"keyword": "Show"}))
assert [call[1] for call in calls] == ["AliasA", "AliasA", "AliasB", "AliasB"]
calls.clear()
await events(SearchManualOwner.events(owner, params={
"keyword": "Show", "page": 3, "source": encode_source("1", "AliasB")}))
assert calls == [(1, "AliasB", 2), (1, "AliasB", 3)]
@pytest.mark.asyncio
async def test_zero_display_rows_still_have_more(manual_owner, monkeypatch):
"""
标题预览和最终结果都为空时,原始页事实仍允许继续加载。
"""
from app.chain.search.manual import _ManualPage
owner, _, _ = manual_owner
# 过滤规则把本页结果全部过滤掉:显示为空,但站点仍有后续页。
monkeypatch.setattr(_ManualPage, "results", lambda _self, _torrents=None: [])
result = await events(SearchManualOwner.events(owner, params={"keyword": "Show", "sites": [1]}))
assert result[-2]["items"] == []
assert all(not item["items"] for item in result if item["type"] == "append")
assert all(item["can_continue"] for item in site_facts(result))
@pytest.mark.asyncio
async def test_empty_raw_page_has_no_more(manual_owner, monkeypatch):
"""
原始页面为空时标记该来源不能继续分页。
"""
owner, _, _ = manual_owner
def request(**_params):
report_site_search_outcome(attempted=True, outcome="success")
report_site_search_page(raw_count=0)
return []
monkeypatch.setattr(owner, "search_site_torrents", request)
result = await events(SearchManualOwner.events(owner, params={
"keyword": "Show", "page": 4, "source": encode_source("1", "Show")}))
assert [(item["page"], item["can_continue"]) for item in result[-1]["sources"]] == [(4, False)]
@pytest.mark.asyncio
async def test_unidentifiable_resource_does_not_fail_the_page(manual_owner, monkeypatch):
"""
单个资源无法识别不应使整页被标记为失败。
"""
owner, _, _ = manual_owner
def request(**params):
report_site_search_outcome(attempted=True, outcome="success")
report_site_search_page(raw_count=2, has_more=True)
return [TorrentInfo(site=params["site"]["id"], description="x", title=""),
TorrentInfo(site=params["site"]["id"], description="y", title="Show S01E01")]
monkeypatch.setattr(owner, "search_site_torrents", request)
result = await events(SearchManualOwner.events(owner, params={"keyword": "Show", "sites": [1]}))
assert result[-1]["sources"][0]["error"] is None
@pytest.mark.asyncio
async def test_site_without_pagination_has_only_the_first_page(manual_owner, monkeypatch):
"""
不支持分页的站点只请求首页,后续继续请求不访问站点。
"""
owner, calls, _ = manual_owner
monkeypatch.setattr(owner, "get_search_page_size", lambda **_params: None)
first = await events(SearchManualOwner.events(owner, params={"keyword": "Show", "sites": [1]}))
assert not site_facts(first)[0]["can_continue"]
calls.clear()
later = await events(SearchManualOwner.events(owner, params={
"keyword": "Show", "page": 1, "source": encode_source("1", "Show")}))
assert calls == []
assert not later[-1]["sources"][0]["can_continue"]
@pytest.mark.asyncio
@pytest.mark.parametrize("filter_all", [False, True])
async def test_raw_page_signature_stops_only_the_identical_adjacent_page(manual_owner, monkeypatch, filter_all):
"""
原始页摘要独立于展示过滤,仅相邻页完全重复时结束来源。
"""
from app.chain.search.manual import _ManualPage
owner, calls, _ = manual_owner
def request(**params):
page = params["page"]
calls.append(page)
numbers = [1, 2] if page == 0 else [1, 3]
report_site_search_outcome(attempted=True, outcome="success")
# 站点不给总页数:不能为了确定还有没有结果而预取后页。
report_site_search_page(raw_count=len(numbers))
return [TorrentInfo(site=1, site_name="A", title=f"Show S01E{number:02d}", description=str(number))
for number in numbers]
monkeypatch.setattr(owner, "search_site_torrents", request)
if filter_all:
monkeypatch.setattr(_ManualPage, "results", lambda _self, _torrents=None: [])
first = await events(SearchManualOwner.events(owner, params={"keyword": "Show", "sites": [1]}))
first_source = site_facts(first)[0]
assert calls == [0]
assert first_source["can_continue"]
second = await events(SearchManualOwner.events(owner, params={
"keyword": "Show", "page": 1, "source": first_source["source"]}))
second_source = site_facts(second)[0]
assert calls == [0, 1]
assert second_source["can_continue"]
assert len(second[-2]["items"]) == (0 if filter_all else 2)
repeated = await events(SearchManualOwner.events(owner, params={
"keyword": "Show", "page": 2, "source": second_source["source"]}))
assert calls == [0, 1, 2]
assert repeated[-2]["items"] == []
assert not site_facts(repeated)[0]["can_continue"]
@pytest.mark.asyncio
async def test_pages_beyond_the_cap_are_not_requested(manual_owner):
"""
手动分页只能请求第 0–19 页,到第 20 页不发请求,第 19 页后不可继续。
"""
owner, calls, _ = manual_owner
result = await events(SearchManualOwner.events(owner, params={
"keyword": "Show", "page": 20, "source": encode_source("1", "Show")}))
assert calls == []
assert not result[-1]["sources"][0]["can_continue"]
last = await events(SearchManualOwner.events(owner, params={
"keyword": "Show", "page": 19, "source": encode_source("1", "Show")}))
assert calls == [(1, "Show", 18), (1, "Show", 19)]
assert not last[-1]["sources"][0]["can_continue"]
@pytest.mark.asyncio
async def test_invalid_source_is_rejected(manual_owner):
"""
来源标识无效时返回明确错误,不发起搜索请求。
"""
owner, calls, _ = manual_owner
result = await events(SearchManualOwner.events(owner, params={"keyword": "Show", "page": 1, "source": "bad!"}))
assert result == [{"type": "error", "message": "搜索来源无效,请重新搜索"}]
assert calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize("season,expected", [(None, [0, 1, 2]), (0, [0]), (1, [1]), (2, [2])])
async def test_media_page_keeps_original_season_filter(manual_owner, monkeypatch, season, expected):
"""
手动精确搜索复用原季范围;过滤后的条数不改变原始页事实。
"""
from unittest.mock import AsyncMock
from app.domain.context import MediaInfo
from app.schemas.types import MediaSource
owner, _, _ = manual_owner
media = MediaInfo(media_source=MediaSource.TMDB, media_id="1", tmdb_id=1, type=MediaType.TV,
title="Example Show", original_title="Example Show", names=["Example Show"])
# 与预览测试使用同一实例边界,确保精确搜索不会访问真实 TMDB。
recognizer = manual_module.MediaChain()
monkeypatch.setattr(recognizer, "async_recognize_media", AsyncMock(return_value=media))
monkeypatch.setattr(recognizer, "async_supplement_media_info", AsyncMock(return_value=media))
def request(**_params):
report_site_search_outcome(attempted=True, outcome="success")
report_site_search_page(raw_count=3, has_more=True)
return [TorrentInfo(site=1, site_name="A", title=f"Example Show S0{number}E01",
description=f"season {number}", enclosure=f"https://site.example/download?id={number}")
for number in range(3)]
monkeypatch.setattr(owner, "search_site_torrents", request)
params = {"media_source": MediaSource.TMDB, "media_id": "1", "mtype": MediaType.TV, "sites": [1],
"season": season}
result = await events(SearchManualOwner.events(owner, params=params))
assert sorted(item["meta_info"]["begin_season"] for item in result[-2]["items"]) == expected
assert site_facts(result)[0]["can_continue"]
@pytest.mark.asyncio
@pytest.mark.parametrize("elapsed,expected", [(36 * 3600 - 1, [3]), (36 * 3600 + 1, [2, 3])])
async def test_previous_page_cache_expires_after_thirty_six_hours(manual_owner, page_clock, elapsed, expected):
"""
前页缓存超过三十六小时后,继续搜索前需恢复前页状态。
"""
owner, calls, (_, saved) = manual_owner
source = encode_source("1", "Show")
await events(SearchManualOwner.events(owner, params={"keyword": "Show", "page": 2, "source": source}))
calls.clear()
page_clock[0] += elapsed
result = await events(SearchManualOwner.events(owner, params={"keyword": "Show", "page": 3, "source": source}))
assert [page for _site, _keyword, page in calls] == expected
assert [item["torrent_info"]["description"] for item in result[-2]["items"]] == ["1-3"]
assert result[-1]["sources"][0]["page"] == 3
assert "signature" not in result[-1]["sources"][0]
assert saved == []
@pytest.mark.asyncio
@pytest.mark.parametrize("failed_page,retried_pages", [(2, [2, 3]), (3, [3])])
async def test_expired_page_recovery_keeps_target_on_timeout(manual_owner, failed_page, retried_pages):
"""
过期前页恢复超时时保留目标页,允许后续重试。
"""
owner, calls, (failures, _) = manual_owner
source = encode_source("1", "Show")
failures.add((1, failed_page))
failed = await events(SearchManualOwner.events(owner, params={"keyword": "Show", "page": 3, "source": source}))
assert [page for _site, _keyword, page in calls] == ([2] if failed_page == 2 else [2, 3])
assert failed[-2]["items"] == []
assert failed[-1]["sources"][0] == {
"source": source, "site_name": "A", "page": 3, "can_continue": True, "error": "timeout"}
failures.clear()
calls.clear()
retried = await events(SearchManualOwner.events(owner, params={"keyword": "Show", "page": 3, "source": source}))
assert [page for _site, _keyword, page in calls] == retried_pages
assert [item["torrent_info"]["description"] for item in retried[-2]["items"]] == ["1-3"]
@pytest.mark.asyncio
async def test_refreshed_previous_page_drives_duplicate_detection(manual_owner, monkeypatch, page_clock):
"""
继续搜索以刷新后的前页内容判定重复页面。
"""
owner, calls, _ = manual_owner
source = encode_source("1", "Show")
await events(SearchManualOwner.events(owner, params={"keyword": "Show", "page": 2, "source": source}))
page_clock[0] += 36 * 3600 + 1
calls.clear()
def request(**params):
calls.append(params["page"])
report_site_search_outcome(attempted=True, outcome="success")
report_site_search_page(raw_count=1)
return [TorrentInfo(site=1, site_name="A", title="Show S01E09", description="fresh-last-page")]
monkeypatch.setattr(owner, "search_site_torrents", request)
result = await events(SearchManualOwner.events(owner, params={"keyword": "Show", "page": 3, "source": source}))
assert calls == [2, 3]
assert result[-2]["items"] == []
assert not result[-1]["sources"][0]["can_continue"]
@pytest.mark.asyncio
async def test_same_page_retry_compares_with_previous_page_not_itself(manual_owner):
"""
同页重试与真正的前页比较,不能误判为重复末页。
"""
owner, calls, _ = manual_owner
first = await events(SearchManualOwner.events(owner, params={"keyword": "Show", "sites": [1]}))
source = site_facts(first)[0]["source"]
params = {"keyword": "Show", "page": 1, "source": source}
second = await events(SearchManualOwner.events(owner, params=params))
retried = await events(SearchManualOwner.events(owner, params=params))
assert [page for _site, _keyword, page in calls] == [0, 1, 1]
assert retried[-2]["items"] == second[-2]["items"]
assert len(retried[-2]["items"]) == 1
assert site_facts(retried)[0]["can_continue"]
@pytest.mark.asyncio
async def test_search_rounds_do_not_share_previous_page(manual_owner, monkeypatch):
"""
独立搜索轮次不共享前页缓存,避免跨轮误判重复。
"""
owner, _, _ = manual_owner
first_pages = iter(["A", "B"])
def request(**params):
description = next(first_pages) if params["page"] == 0 else "B"
report_site_search_outcome(attempted=True, outcome="success")
report_site_search_page(raw_count=1)
return [TorrentInfo(site=1, site_name="A", title="Show S01E01", description=description)]
monkeypatch.setattr(owner, "search_site_torrents", request)
params = {"keyword": "Show", "sites": [1]}
first = await events(SearchManualOwner.events(owner, params=params))
other = await events(SearchManualOwner.events(owner, params=params))
first_source, other_source = site_facts(first)[0]["source"], site_facts(other)[0]["source"]
assert first_source != other_source
continued = await events(SearchManualOwner.events(owner, params={**params, "page": 1, "source": first_source}))
repeated = await events(SearchManualOwner.events(owner, params={**params, "page": 1, "source": other_source}))
assert len(continued[-2]["items"]) == 1 and site_facts(continued)[0]["can_continue"]
assert repeated[-2]["items"] == [] and not site_facts(repeated)[0]["can_continue"]
@pytest.mark.asyncio
async def test_recovery_requests_both_pages_through_existing_site_interval(manual_owner, monkeypatch, page_clock):
"""
恢复前页与请求目标页共用已有站点请求间隔。
"""
import app.application.site.throttle as site_throttle
import app.runtime.rate as rate
owner, calls, _ = manual_owner
monkeypatch.setattr(owner, "_sync_indexers", lambda _ids: [{"id": 1, "name": "A", "limit_seconds": 10}])
waits = []
def sleep(delay):
"""
推进分页搜索使用的虚拟时钟。
"""
waits.append(delay)
page_clock[0] += delay
clock = SimpleNamespace(monotonic=lambda: page_clock[0], sleep=sleep)
monkeypatch.setattr(site_throttle, "time", clock)
monkeypatch.setattr(rate, "time", clock)
result = await events(SearchManualOwner.events(owner, params={
"keyword": "Show", "page": 3, "source": encode_source("1", "Show")}))
assert [page for _site, _keyword, page in calls] == [2, 3]
assert waits == [10]
assert [item["torrent_info"]["description"] for item in result[-2]["items"]] == ["1-3"]
@pytest.mark.asyncio
async def test_diagnostics_distinguishes_empty_error_and_cooldown_without_secrets(manual_owner, monkeypatch):
"""
页诊断只包含实际调度来源,保留空结果、请求失败与未发请求冷却的区别。
"""
owner, _, _ = manual_owner
def request(**params):
"""
模拟含敏感地址的失败与未发请求的冷却,不访问外部站点。
"""
first = params["site"]["id"] == 1
report_site_search_outcome(
attempted=first, outcome="error" if first else "deferred",
error="https://secret.example/download?passkey=private&token=private",
)
return []
monkeypatch.setattr(owner, "search_site_torrents", request)
result = await events(SearchManualOwner.events(owner, params={"keyword": "Show"}))
diagnostics = result[-1]["diagnostics"]
sites = {site["site_id"]: site for site in diagnostics["searched_sites"]}
assert sites[1]["outcome"] == "error" and sites[1]["attempted"] is True
assert sites[2]["outcome"] == "cooldown" and sites[2]["attempted"] is False
assert sites[None]["outcome"] == "empty"
assert diagnostics["scope"] == "page"
assert diagnostics["stage_counts"] == {"candidates": 0, "retained": 0}
assert diagnostics["rejection_reasons"] == []
assert {warning["code"] for warning in diagnostics["warnings"]} == {"provider_error", "provider_cooldown"}
assert not any(value in str(diagnostics) for value in ("secret.example", "passkey", "token=", "private"))
@pytest.mark.asyncio
async def test_diagnostics_keeps_rule_skip_separate_from_resource_rejection(manual_owner, monkeypatch):
"""
只适用外语电影的规则不会把动画电影资源伪报为被规则拒绝。
"""
from app.application import rules as rule_module
from app.schemas.rule import FilterRuleGroup
owner, _, _ = manual_owner
rule = FilterRuleGroup(name="中文字幕", media_type="电影", category="外语电影", rule_string="1080P")
monkeypatch.setattr(rule_module.RuleHelper, "get_rule_groups", lambda _self: [rule])
monkeypatch.setattr(manual_module, "get_configured_system_config", lambda: SimpleNamespace(get=lambda _key: ["中文字幕"]))
media = MediaInfo(media_source=MediaSource.TMDB, media_id="1", tmdb_id=1, type=MediaType.MOVIE,
title="Show", original_title="Show", names=["Show"], category="动画电影")
recognizer = manual_module.MediaChain()
monkeypatch.setattr(recognizer, "async_recognize_media", AsyncMock(return_value=media))
monkeypatch.setattr(recognizer, "async_supplement_media_info", AsyncMock(return_value=media))
# 过滤模块没有适用组时保留全部输入,诊断不能改变这一既有合同。
monkeypatch.setattr(owner, "filter_torrents", lambda **params: params["torrent_list"])
result = await events(SearchManualOwner.events(owner, params={"media_source": MediaSource.TMDB, "media_id": "1"}))
diagnostics = result[-1]["diagnostics"]
assert diagnostics["rule_groups"] == [{
"name": "中文字幕", "status": "skipped", "reason": "rule_group_not_applicable",
"media_type": "电影", "categories": ["外语电影"],
}]
assert {warning["code"] for warning in diagnostics["warnings"]} == {"rule_group_not_applicable"}
assert all(reason["code"] != "filter_rules" for reason in diagnostics["rejection_reasons"])
@pytest.mark.asyncio
async def test_page_diagnostics_and_context_bind_to_current_user_and_search(manual_owner, monkeypatch):
"""
首页按认证用户一次发布完整快照,续页沿用搜索编号但不覆盖首页结果。
"""
owner, _, _ = manual_owner
save_context = AsyncMock()
monkeypatch.setattr(owner, "async_save_search_context", save_context)
first = await events(SearchManualOwner.events(owner, params={"keyword": "Show", "user_id": 31, "sites": [1]}))
user_id, snapshot = save_context.await_args.args
assert user_id == 31
assert snapshot.params["keyword"] == "Show"
assert len(snapshot.results) == snapshot.diagnostics.stage_counts.retained == 1
assert snapshot.diagnostics.search_id == first[-1]["diagnostics"]["search_id"]
assert {site["site_id"] for site in first[-1]["diagnostics"]["searched_sites"]} == {1, None}
source = site_facts(first)[0]["source"]
next_page = await events(SearchManualOwner.events(owner, params={
"keyword": "Show", "user_id": 31, "page": 1, "source": source,
}))
assert next_page[-1]["diagnostics"]["search_id"] == first[-1]["diagnostics"]["search_id"]
assert [site["site_id"] for site in next_page[-1]["diagnostics"]["searched_sites"]] == [1]
assert save_context.await_count == 1
other = await events(SearchManualOwner.events(owner, params={"keyword": "Other", "user_id": 32, "sites": [2]}))
assert other[-1]["diagnostics"]["search_id"] != first[-1]["diagnostics"]["search_id"]
assert save_context.await_args.args[0] == 32
@pytest.mark.asyncio
async def test_cancelled_page_never_publishes_diagnostics_snapshot(manual_owner, blocked_site, monkeypatch):
"""
关闭预览等待工作线程结束,但不发布当前用户的完成快照。
"""
owner, _, _ = manual_owner
_, release, _, _ = blocked_site
save_context = AsyncMock()
monkeypatch.setattr(owner, "async_save_search_context", save_context)
stream = SearchManualOwner.events(owner, params={"keyword": "Show", "user_id": 31})
try:
while not (await anext(stream)).get("items"):
pass
release.set()
await stream.aclose()
finally:
release.set()
await stream.aclose()
save_context.assert_not_awaited()