from types import SimpleNamespace from app.api.endpoints.transfer import ( manual_transfer as manual_transfer_endpoint, query_manual_transfer_history, ) from app.chain.transfer import TransferChain from app.runtime.config import settings from app.db.oper.transferhistory import TransferHistoryOper from app.application.history import ( clear_transfer_failures, failed_retry_count, max_failed_retries, record_transfer_failure, ) from app.schemas import ManualTransferItem from tests.test_transfer_sync_extra_files import ( FakeMeta, make_fileitem, make_transfer_chain, ) def _reset_failed_retries(src_path, storage=None): """清空失败重试计数,隔离用例之间共享的模块级计数缓存。""" clear_transfer_failures(src_path, storage) def _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, deleted): """隔离整理规划依赖,并记录历史及旧目标清理动作。""" monkeypatch.setattr( chain, "_TransferChain__get_trans_fileitems", lambda current_fileitem, predicate: [(fileitem, False)], ) monkeypatch.setattr(chain, "_TransferChain__put_to_jobview", lambda task: True) monkeypatch.setattr( chain, "_register_scrape_batch_task", lambda task: None, ) monkeypatch.setattr( chain, "_close_scrape_batch", lambda batch_id: None, ) def fake_handle_transfer(task, callback=None): """记录进入实际整理阶段的文件。""" planned.append(task.fileitem.path) return True, "" monkeypatch.setattr(chain, "_TransferChain__handle_transfer", fake_handle_transfer) history_oper = SimpleNamespace( get_by_src=lambda src, storage=None: history, get_success_by_src=lambda src, storage=None: history if history and history.status else None, get_by_dest=lambda dest, storage=None: None, delete=lambda history_id: deleted.append(("history", history_id)), ) monkeypatch.setattr( "app.chain.transfer.TransferHistoryOper", lambda: history_oper, ) monkeypatch.setattr("app.chain._transfer.TransferHistoryOper", lambda: history_oper) monkeypatch.setattr( "app.chain.transfer.DownloadHistoryOper", lambda: SimpleNamespace( get_by_hash=lambda download_hash: None, get_file_by_fullpath=lambda fullpath: None, get_files_by_savepath=lambda savepath: [], get_by_path=lambda path: None, ), ) monkeypatch.setattr("app.chain._transfer.DownloadHistoryOper", lambda: SimpleNamespace( get_by_hash=lambda download_hash: None, get_file_by_fullpath=lambda fullpath: None, get_files_by_savepath=lambda savepath: [], get_by_path=lambda path: None, )) monkeypatch.setattr( "app.chain.transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None), ) monkeypatch.setattr("app.chain._transfer.get_configured_system_config", lambda: SimpleNamespace(get=lambda key: None)) monkeypatch.setattr( "app.chain.transfer.StorageChain", lambda: SimpleNamespace( exists=lambda current_fileitem: True, delete_media_file=lambda current_fileitem: deleted.append( ("target", current_fileitem.path) ) or True, ), ) monkeypatch.setattr("app.chain._transfer.StorageChain", lambda: SimpleNamespace( exists=lambda current_fileitem: True, delete_media_file=lambda current_fileitem: deleted.append( ("target", current_fileitem.path) ) or True, )) monkeypatch.setattr( "app.chain.transfer.MetaInfoPath", lambda path, custom_words=None, **kwargs: FakeMeta(1), ) def test_query_manual_transfer_history_returns_success_summary(monkeypatch): """手动整理初始化接口应只返回成功历史摘要供前端切换重整状态。""" fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") captured = [] class _FakeTransferChain: """记录历史查询收到的文件项。""" def get_manual_transfer_histories(self, fileitems): """返回两条成功历史记录。""" captured.extend(fileitems) return [SimpleNamespace(id=1), SimpleNamespace(id=2)] monkeypatch.setattr( "app.api.endpoints.transfer.TransferChain", _FakeTransferChain, ) response = query_manual_transfer_history( transer_item=ManualTransferItem(fileitem=fileitem), history_query=SimpleNamespace(get=lambda _history_id: None), _="token", ) assert response.success is True assert response.data == {"reorganize": True, "history_count": 2} assert captured == [fileitem] def test_manual_transfer_endpoint_passes_reorganize_confirmation(monkeypatch): """手动整理接口应把前端重整确认传入整理链。""" fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") captured = {} class _FakeTransferChain: """记录手动整理调用参数。""" def manual_transfer(self, **kwargs): """保存整理参数并返回成功。""" captured.update(kwargs) return True, "" monkeypatch.setattr( "app.api.endpoints.transfer.TransferChain", _FakeTransferChain, ) response = manual_transfer_endpoint( transer_item=ManualTransferItem( fileitem=fileitem, reorganize=True, ), background=False, history_query=SimpleNamespace(get=lambda _history_id: None), _="token", ) assert response.success is True assert captured["reorganize"] is True def test_history_endpoint_reorganize_uses_chain_cleanup(monkeypatch): """历史 ID 重整应交由整理链清理历史,不能再传入旧目标重复删除。""" src_fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") dest_fileitem = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv") history = SimpleNamespace( id=21, status=True, mode="copy", src_fileitem=src_fileitem.model_dump(), dest_fileitem=dest_fileitem.model_dump(), ) captured = {} class _FakeTransferChain: """记录历史 ID 重整调用参数。""" def manual_transfer(self, **kwargs): """保存整理参数并返回成功。""" captured.update(kwargs) return True, "" monkeypatch.setattr( "app.api.endpoints.transfer.TransferChain", _FakeTransferChain, ) response = manual_transfer_endpoint( transer_item=ManualTransferItem( logid=history.id, reorganize=True, ), background=False, history_query=SimpleNamespace(get=lambda _history_id: history), _="token", ) assert response.success is True assert captured["force"] is True assert captured["reorganize"] is True assert captured["cleanup_dest_fileitem"] is None def test_success_history_directory_query_excludes_failed_and_siblings(): """目录历史查询应限定路径边界,并且只返回成功记录。""" transfer_history_oper = TransferHistoryOper() created_histories = [ transfer_history_oper.add_force( src="/issue-6191/show/episode-1.mkv", src_storage="local", status=True, ), transfer_history_oper.add_force( src="/issue-6191/show/episode-2.mkv", src_storage="local", status=False, ), transfer_history_oper.add_force( src="/issue-6191/show-other/episode-3.mkv", src_storage="local", status=True, ), ] try: histories = transfer_history_oper.list_success_by_src( "/issue-6191/show", storage="local", recursive=True, ) assert [history.src for history in histories] == [ "/issue-6191/show/episode-1.mkv" ] finally: for history in created_histories: transfer_history_oper.delete(history.id) def test_success_history_directory_query_escapes_sql_wildcards(): """目录名中的 SQL 通配符应按普通字符匹配,不能扩大查询范围。""" transfer_history_oper = TransferHistoryOper() created_histories = [ transfer_history_oper.add_force( src="/issue-6191/show_100%/episode-1.mkv", src_storage="local", status=True, ), transfer_history_oper.add_force( src="/issue-6191/showA100B/episode-2.mkv", src_storage="local", status=True, ), ] try: histories = transfer_history_oper.list_success_by_src( "/issue-6191/show_100%", storage="local", recursive=True, ) assert [history.src for history in histories] == [ "/issue-6191/show_100%/episode-1.mkv" ] finally: for history in created_histories: transfer_history_oper.delete(history.id) def test_successful_move_history_is_found_by_current_destination(): """成功移动后从媒体库现址打开整理界面时,应识别原整理历史。""" transfer_history_oper = TransferHistoryOper() destination = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv") history = transfer_history_oper.add_force( src="/downloads/Test.Show.S01E01.mkv", src_storage="local", dest=destination.path, dest_storage="local", dest_fileitem=destination.model_dump(), mode="move", status=True, ) try: histories = TransferChain.get_manual_transfer_histories( make_transfer_chain(), [destination], ) assert [current.id for current in histories] == [history.id] finally: transfer_history_oper.delete(history.id) def test_manual_transfer_removes_failed_history_before_retry(monkeypatch): """失败历史不应阻塞手动整理,并应在重试前清理旧目标和记录。""" chain = make_transfer_chain() fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") old_dest = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv") history = SimpleNamespace( id=7, status=False, mode="copy", dest_fileitem=old_dest.model_dump(), download_hash=None, downloader=None, src=None, src_storage=None, ) planned = [] deleted = [] _patch_transfer_planning( monkeypatch, chain, fileitem, history, planned, deleted, ) state, message = TransferChain.do_transfer( chain, fileitem=fileitem, background=False, manual=True, ) assert state is True assert message == "" assert deleted == [ ("target", old_dest.path), ("history", history.id), ] assert planned == [fileitem.path] def test_automatic_transfer_retries_failed_history_within_retry_budget(monkeypatch): """失败重试次数未达上限(默认计数为 0)时,自动整理遇到失败记录应放行重试,避免一次瞬时故障永久锁死文件。""" chain = make_transfer_chain() fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") history = SimpleNamespace( id=12, status=False, mode="copy", dest_fileitem=make_fileitem( "/library/Test Show/Test.Show.S01E01.mkv" ).model_dump(), download_hash=None, downloader=None, src=fileitem.path, src_storage=fileitem.storage, ) planned = [] deleted = [] _patch_transfer_planning( monkeypatch, chain, fileitem, history, planned, deleted, ) _reset_failed_retries(fileitem.path, fileitem.storage) try: state, message = TransferChain.do_transfer( chain, fileitem=fileitem, background=False, manual=False, ) assert state is True assert message == "" assert deleted == [] assert planned == [fileitem.path] finally: _reset_failed_retries(fileitem.path, fileitem.storage) def test_automatic_transfer_skips_failed_history_when_retry_budget_exhausted(monkeypatch): """失败重试次数已达上限时,自动整理遇到失败记录仍应被拦截、不进入整理链,避免失败任务自动循环。""" monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 1) chain = make_transfer_chain() fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") history = SimpleNamespace( id=13, status=False, mode="copy", dest_fileitem=make_fileitem( "/library/Test Show/Test.Show.S01E01.mkv" ).model_dump(), download_hash=None, downloader=None, src=fileitem.path, src_storage=fileitem.storage, ) planned = [] deleted = [] _patch_transfer_planning( monkeypatch, chain, fileitem, history, planned, deleted, ) _reset_failed_retries(fileitem.path, fileitem.storage) try: record_transfer_failure(fileitem.path, fileitem.storage) state, message = TransferChain.do_transfer( chain, fileitem=fileitem, background=False, manual=False, ) assert state is False assert message == f"{fileitem.name} 已整理过" assert deleted == [] assert planned == [] finally: _reset_failed_retries(fileitem.path, fileitem.storage) def test_automatic_transfer_new_version_bypasses_exhausted_retry_budget(monkeypatch): """失败预算耗尽后同路径新版本必须进入整理,且不能给下载任务补打已整理标签。""" monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 1) chain = make_transfer_chain() fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") fileitem.size = 200 history = SimpleNamespace( id=15, status=False, mode="copy", dest_fileitem=None, download_hash="abc123", downloader="qbittorrent", src=fileitem.path, src_storage=fileitem.storage, src_fileitem={"size": 100}, ) planned = [] deleted = [] _patch_transfer_planning( monkeypatch, chain, fileitem, history, planned, deleted, ) completed = [] chain.transfer_completed = lambda download_hash, downloader: completed.append( (download_hash, downloader) ) _reset_failed_retries(fileitem.path, fileitem.storage) try: record_transfer_failure(fileitem.path, fileitem.storage, file_size=100) state, message = TransferChain.do_transfer( chain, fileitem=fileitem, downloader="qbittorrent", download_hash="abc123", background=False, manual=False, ) assert state is True assert message == "" assert deleted == [] assert planned == [fileitem.path] assert completed == [] finally: _reset_failed_retries(fileitem.path, fileitem.storage) def test_manual_transfer_bypasses_retry_budget_when_exhausted(monkeypatch): """ 手动整理不受重试次数上限限制:manual=True 时查重闸根本不参与判定 (do_transfer 中 `if transferd and not manual` 已把 manual 路径排除在外), 即使失败计数已达上限(自动路径会因此拦截),仍应清理失败记录、重置计数并放行重试。 """ monkeypatch.setattr(settings, "TRANSFER_MAX_FAILED_RETRIES", 1) chain = make_transfer_chain() fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") old_dest = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv") history = SimpleNamespace( id=14, status=False, mode="copy", dest_fileitem=old_dest.model_dump(), download_hash=None, downloader=None, src=fileitem.path, src_storage=fileitem.storage, ) planned = [] deleted = [] _patch_transfer_planning( monkeypatch, chain, fileitem, history, planned, deleted, ) _reset_failed_retries(fileitem.path, fileitem.storage) try: record_transfer_failure(fileitem.path, fileitem.storage) assert failed_retry_count(fileitem.path, fileitem.storage) >= max_failed_retries() state, message = TransferChain.do_transfer( chain, fileitem=fileitem, background=False, manual=True, ) assert state is True assert message == "" assert deleted == [ ("target", old_dest.path), ("history", history.id), ] assert planned == [fileitem.path] assert failed_retry_count(fileitem.path, fileitem.storage) == 0 finally: _reset_failed_retries(fileitem.path, fileitem.storage) def test_manual_transfer_keeps_success_history_without_confirmation(monkeypatch): """未携带重整标志时,成功历史仍应阻止普通手动整理。""" chain = make_transfer_chain() fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") history = SimpleNamespace( id=8, status=True, mode="copy", dest_fileitem=make_fileitem( "/library/Test Show/Test.Show.S01E01.mkv" ).model_dump(), download_hash=None, downloader=None, ) planned = [] deleted = [] _patch_transfer_planning( monkeypatch, chain, fileitem, history, planned, deleted, ) state, message = TransferChain.do_transfer( chain, fileitem=fileitem, background=False, manual=True, ) assert state is True assert message == f"{fileitem.name} 已整理过" assert deleted == [] assert planned == [] def test_manual_reorganize_removes_success_history_and_old_target(monkeypatch): """用户确认重新整理后,应清理成功历史及非移动模式旧目标再执行。""" chain = make_transfer_chain() fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") old_dest = make_fileitem("/library/Wrong Show/Wrong.Show.S02E01.mkv") history = SimpleNamespace( id=9, status=True, mode="copy", dest_fileitem=old_dest.model_dump(), download_hash=None, downloader=None, src=None, src_storage=None, ) planned = [] deleted = [] _patch_transfer_planning( monkeypatch, chain, fileitem, history, planned, deleted, ) state, message = TransferChain.do_transfer( chain, fileitem=fileitem, background=False, manual=True, reorganize=True, ) assert state is True assert message == "" assert deleted == [ ("target", old_dest.path), ("history", history.id), ] assert planned == [fileitem.path] def test_manual_reorganize_keeps_successful_move_target_as_source(monkeypatch): """成功移动后的目标是当前重整源,只能删历史记录,不能先删除文件。""" chain = make_transfer_chain() fileitem = make_fileitem("/library/Test Show/Test.Show.S01E01.mkv") history = SimpleNamespace( id=10, status=True, mode="move", dest_fileitem=fileitem.model_dump(), download_hash=None, downloader=None, src=None, src_storage=None, ) planned = [] deleted = [] _patch_transfer_planning( monkeypatch, chain, fileitem, history, planned, deleted, ) state, message = TransferChain.do_transfer( chain, fileitem=fileitem, background=False, manual=True, reorganize=True, ) assert state is True assert message == "" assert deleted == [("history", history.id)] assert planned == [fileitem.path] def test_forced_manual_reorganize_still_removes_history(monkeypatch): """从历史入口强制重新整理时,仍应按重整确认清理旧目标和记录。""" chain = make_transfer_chain() fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") old_dest = make_fileitem("/library/Wrong Show/Wrong.Show.S02E01.mkv") history = SimpleNamespace( id=11, status=True, mode="copy", dest_fileitem=old_dest.model_dump(), download_hash=None, downloader=None, src=None, src_storage=None, ) planned = [] deleted = [] _patch_transfer_planning( monkeypatch, chain, fileitem, history, planned, deleted, ) state, message = TransferChain.do_transfer( chain, fileitem=fileitem, background=False, manual=True, force=True, reorganize=True, ) assert state is True assert message == "" assert deleted == [ ("target", old_dest.path), ("history", history.id), ] assert planned == [fileitem.path]