From f03b5fb74cdfbe21f10697236668c5beac08b73d Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 10 Oct 2026 17:51:24 +0800 Subject: [PATCH] fix(workflow): accept RSS domain context when adding downloads (#6947) --- app/workflow/actions/add_download.py | 7 +- tests/test_workflow_add_download.py | 117 +++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 tests/test_workflow_add_download.py diff --git a/app/workflow/actions/add_download.py b/app/workflow/actions/add_download.py index 5eb277b82..51896a0a0 100644 --- a/app/workflow/actions/add_download.py +++ b/app/workflow/actions/add_download.py @@ -132,11 +132,14 @@ class AddDownloadAction(BaseAction): return context @staticmethod - def _to_domain_context(context: WorkflowTorrentContext) -> DownloadContext: - """把工作流传输模型转换为下载链使用的领域上下文和媒体类型。""" + def _to_domain_context(context: WorkflowTorrentContext | DownloadContext) -> DownloadContext: + """复用 RSS 的领域上下文,或把工作流传输模型还原为下载链领域对象。""" if context.media_info is None or context.torrent_info is None: raise ValueError("工作流下载上下文缺少媒体或种子信息") + if isinstance(context, DownloadContext): + return context + context_data: dict[str, Any] = context.model_dump() media_data = context_data.get("media_info") if not isinstance(media_data, dict): diff --git a/tests/test_workflow_add_download.py b/tests/test_workflow_add_download.py new file mode 100644 index 000000000..8372c1f4a --- /dev/null +++ b/tests/test_workflow_add_download.py @@ -0,0 +1,117 @@ +"""覆盖 RSS 领域上下文通过工作流动作桥接后提交下载的契约。""" + +from unittest.mock import Mock + +import pytest + +from app.domain.context import Context, MediaInfo, TorrentInfo +from app.domain.meta.metabase import MetaBase +from app.domain.metainfo import MetaInfo +from app.schemas.types import MediaType +from app.schemas.workflow import ActionContext +from app.workflow.actions import add_download, fetch_rss, filter_torrents +from app.workflow.actions.add_download import AddDownloadAction +from app.workflow.actions.fetch_rss import FetchRssAction +from app.workflow.actions.filter_torrents import FilterTorrentsAction + + +@pytest.mark.parametrize("match_media", [False, True]) +@pytest.mark.parametrize("only_lack", [False, True]) +def test_rss_filter_add_download_accepts_domain_context(monkeypatch, match_media, only_lack): + """RSS 无论是否预先识别,都应经资源过滤及动作输入桥接正常提交下载。""" + media = MediaInfo(title="Show", type=MediaType.TV, tmdb_id=123) + rss_helper = Mock() + rss_helper.parse.return_value = [{ + "title": "Show S01E03", + "enclosure": "https://example.com/show.torrent", + "link": "https://example.com/details/1", + "size": 1024, + }] + media_chain = Mock() + media_chain.recognize_by_meta.return_value = media + download_chain = Mock() + download_chain.media_exists.return_value = None + download_chain.download_single.return_value = "hash-show" + torrent_helper = Mock() + torrent_helper.filter_torrent.return_value = True + filter_chain = Mock() + filter_chain.filter_torrents.return_value = True + monkeypatch.setattr(fetch_rss, "RssHelper", lambda: rss_helper) + monkeypatch.setattr(fetch_rss, "MediaChain", lambda: media_chain) + monkeypatch.setattr(add_download, "MediaChain", lambda: media_chain) + monkeypatch.setattr(add_download, "DownloadChain", lambda: download_chain) + monkeypatch.setattr(filter_torrents, "TorrentHelper", lambda: torrent_helper) + monkeypatch.setattr(filter_torrents, "ActionChain", lambda: filter_chain) + monkeypatch.setattr(fetch_rss.runtime_stop_state, "is_workflow_stopped", lambda _wid: False) + + rss_result = FetchRssAction("rss").execute_with_inputs( + workflow_id=1, + params={"url": "https://example.com/rss.xml", "match_media": match_media}, + inputs={}, + runtime={}, + context=ActionContext(), + ) + source = rss_result.outputs["torrents"][0] + assert isinstance(source, Context) + assert source.media_info is (media if match_media else None) + + filter_result = FilterTorrentsAction("filter").execute_with_inputs( + workflow_id=1, + params={}, + inputs=rss_result.outputs, + runtime={}, + context=ActionContext(), + ) + action = AddDownloadAction("download") + saved_cache = [] + action.check_cache = lambda _wid, _key: False + action.save_cache = lambda _wid, key: saved_cache.append(key) + result = action.execute_with_inputs( + workflow_id=1, + params={"only_lack": only_lack, "downloader": "qb", "save_path": "/downloads", "labels": "rss"}, + inputs=filter_result.outputs, + runtime={}, + context=ActionContext(), + ) + + assert result.success is True + assert action.done is True + download_chain.download_single.assert_called_once_with( + context=source, downloader="qb", save_path="/downloads", label="rss", + ) + assert source.media_info is media + assert isinstance(source.meta_info, MetaBase) + assert isinstance(source.torrent_info, TorrentInfo) + assert media_chain.recognize_by_meta.call_count == 1 + assert download_chain.media_exists.call_count == int(only_lack) + assert saved_cache == ["None-Show S01E03"] + assert result.outputs["downloads"][0].download_id == "hash-show" + assert result.context.downloads[0].downloader == "qb" + + +def test_add_download_reuses_complete_domain_context(): + """领域对象应原样交给下载链,保留剧集限制、识别状态及原始媒体信息。""" + context = Context( + meta_info=MetaInfo("Show S01E03"), + media_info=MediaInfo(title="Show", type=MediaType.TV, tmdb_id=123), + torrent_info=TorrentInfo(title="Show S01E03"), + media_recognize_fail_count=2, + allowed_episodes={3}, + selected_episodes=[3], + resource_source="rss", + ) + + assert AddDownloadAction._to_domain_context(context) is context + + +@pytest.mark.parametrize("missing", ["media_info", "torrent_info"]) +def test_add_download_rejects_incomplete_domain_context(missing): + """原生领域输入仍须满足下载动作的媒体及种子信息完整性要求。""" + context = Context( + media_info=MediaInfo(title="Show", type=MediaType.TV), + torrent_info=TorrentInfo(title="Show S01E03"), + ) + setattr(context, missing, None) + + with pytest.raises(ValueError, match="缺少媒体或种子信息"): + AddDownloadAction._to_domain_context(context)