import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, patch from app.chain.search import SearchChain from app.modules.indexer import IndexerModule from app.runtime.config import settings def make_chain() -> SearchChain: """构造不触发完整启动流程的搜索链。""" chain = object.__new__(SearchChain) chain.get_search_page_size = IndexerModule.get_search_page_size return chain def test_search_returns_plugin_results_without_indexer_sites(): """未配置 PT 站点时,插件资源仍应进入原生资源搜索。""" chain = make_chain() plugin_item = SimpleNamespace(title="Plugin Result", description="") calls = [] chain.search_plugin_torrents = lambda **kwargs: calls.append(kwargs) or [plugin_item] with ( patch("app.chain.search.get_configured_system_config") as system_config_oper, patch("app.chain.search.SitesHelper") as sites_helper, ): system_config_oper.return_value.get.return_value = [] sites_helper.return_value.get_indexers.return_value = [] results = chain._SearchChain__search_all_sites(keyword="keyword") assert results == [plugin_item] assert len(calls) == 1 assert calls[0]["keyword"] == "keyword" def test_search_invokes_plugin_once_with_multiple_indexers(): """多个 PT 站点不应导致插件资源源被重复搜索。""" chain = make_chain() plugin_calls = [] site_calls = [] chain.search_plugin_torrents = lambda **kwargs: plugin_calls.append(kwargs) or [ SimpleNamespace(title="Plugin Result", description="") ] chain.search_site_torrents = lambda **kwargs: site_calls.append(kwargs) or [ SimpleNamespace(title=f"Site {kwargs['site']['id']}", description="") ] with ( patch.object(settings, "SEARCH_RESOURCE_PAGES", 1, create=True), patch("app.chain.search.get_configured_system_config") as system_config_oper, patch("app.chain.search.SitesHelper") as sites_helper, patch("app.chain.search.ProgressHelper") as progress_helper, ): system_config_oper.return_value.get.return_value = [1, 2] sites_helper.return_value.get_indexers.return_value = [ {"id": 1, "name": "站点一"}, {"id": 2, "name": "站点二"}, ] progress_helper.return_value = SimpleNamespace( start=lambda: None, update=lambda **_kwargs: None, end=lambda: None ) results = chain._SearchChain__search_all_sites(keyword="keyword") assert len(plugin_calls) == 1 assert sorted(call["site"]["id"] for call in site_calls) == [1, 2] assert len(results) == 3 def test_async_search_returns_plugin_results_without_indexers(): """异步搜索应支持只有插件资源源的部署方式。""" chain = make_chain() plugin_item = SimpleNamespace(title="Plugin Result", description="") calls = [] async def plugin_search(**kwargs): calls.append(kwargs) return [plugin_item] chain.async_search_plugin_torrents = plugin_search async def run_search(): with ( patch("app.chain.search.get_configured_system_config") as system_config_oper, patch("app.chain.search.SitesHelper") as sites_helper, ): system_config_oper.return_value.get.return_value = [] sites_helper.return_value.async_get_indexers = AsyncMock(return_value=[]) return await chain._SearchChain__async_search_all_sites(keyword="keyword") results = asyncio.run(run_search()) assert results == [plugin_item] assert len(calls) == 1 def test_async_search_stream_emits_plugin_results_once_without_indexers(): """流式搜索完成事件不应重复发送插件资源。""" chain = make_chain() plugin_item = SimpleNamespace(title="Plugin Result", description="") async def plugin_search(**_kwargs): return [plugin_item] chain.async_search_plugin_torrents = plugin_search async def collect_events(): with ( patch("app.chain.search.get_configured_system_config") as system_config_oper, patch("app.chain.search.SitesHelper") as sites_helper, ): system_config_oper.return_value.get.return_value = [] sites_helper.return_value.async_get_indexers = AsyncMock(return_value=[]) return [ event async for event in chain._SearchChain__async_search_all_sites_stream( keyword="keyword" ) ] events = asyncio.run(collect_events()) assert [event["type"] for event in events] == ["append", "done"] assert events[0]["items"] == [plugin_item] assert events[1]["items"] == [] assert events[1]["total_items"] == 1