from types import SimpleNamespace from unittest.mock import Mock, patch import pytest from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker from app.application.transfer.workflow import TransferAdmission, TransferQueueService from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository from app.db.models.transferhistory import TransferHistory from app.db.models.transferpending import TransferPending from app.schemas.file import FileItem from tests.test_transfer_job_manager import make_task, make_transfer_chain def _service(**overrides): """构造可观测整理队列服务及其默认依赖。""" dependencies = { "register_task": Mock(return_value=True), "admit_task": Mock(return_value=TransferAdmission( task_id="task-1", storage="local", src_path="/tmp/demo.mkv", state="accepted", created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", )), "enqueue": Mock(), "before_enqueue": Mock(), "enqueue_failed": Mock(), "remove_task": Mock(), "list_tasks": Mock(return_value=["job"]), "expire_tasks": Mock(), } dependencies.update(overrides) return TransferQueueService(**dependencies), dependencies def test_transfer_queue_service_put_preserves_registration_order(): """入队必须先登记视图和 durable admission,再登记批次并写队列。""" calls = [] service, _ = _service( register_task=lambda _task: calls.append("register") or True, admit_task=lambda _task: calls.append("admit") or TransferAdmission( task_id="task-1", storage="local", src_path="/tmp/demo.mkv", state="accepted", created_at="2026-08-27 10:00:00", updated_at="2026-08-27 10:00:00", ), before_enqueue=lambda _task: calls.append("batch"), enqueue=lambda _item: calls.append("queue"), ) task = make_task(1) assert service.put(task, Mock()) is True assert calls == ["register", "admit", "batch", "queue"] assert task.admission_task_id == "task-1" def test_transfer_queue_service_rejects_duplicate_without_side_effects(): """作业视图拒绝重复任务后不得继续产生队列副作用。""" service, dependencies = _service(register_task=Mock(return_value=False)) assert service.put(make_task(1), Mock()) is False dependencies["before_enqueue"].assert_not_called() dependencies["enqueue"].assert_not_called() dependencies["admit_task"].assert_not_called() def test_transfer_queue_service_blocks_enqueue_when_admission_fails(): """持久化失败必须撤销作业视图,不能继续加入内存队列。""" service, dependencies = _service( admit_task=Mock(side_effect=RuntimeError("db locked")), ) task = make_task(1) with pytest.raises(RuntimeError, match="db locked"): service.put(task, Mock()) dependencies["remove_task"].assert_called_once_with(task.fileitem) dependencies["before_enqueue"].assert_not_called() dependencies["enqueue"].assert_not_called() def test_transfer_queue_service_keeps_admission_when_enqueue_fails(): """内存入队失败必须记录原因并清理视图,durable admission 由仓储保留。""" error = RuntimeError("queue closed") service, dependencies = _service( enqueue=Mock(side_effect=error), ) task = make_task(1) with pytest.raises(RuntimeError, match="queue closed"): service.put(task, Mock()) dependencies["enqueue_failed"].assert_called_once_with(task, error) dependencies["remove_task"].assert_called_once_with(task.fileitem) def test_transfer_queue_service_cleans_up_when_batch_registration_fails(): """准入后的批次登记异常也必须留痕并撤销作业视图。""" error = RuntimeError("batch registration failed") service, dependencies = _service( before_enqueue=Mock(side_effect=error), ) task = make_task(1) with pytest.raises(RuntimeError, match="batch registration failed"): service.put(task, Mock()) dependencies["enqueue_failed"].assert_called_once_with(task, error) dependencies["remove_task"].assert_called_once_with(task.fileitem) dependencies["enqueue"].assert_not_called() def test_transfer_queue_service_commits_admission_before_failed_enqueue(tmp_path): """真实仓储已提交后即使内存入队失败,任务也必须带原因留待恢复。""" engine = create_engine(f"sqlite:///{tmp_path / 'durable-admission.db'}") TransferHistory.__table__.create(engine) TransferPending.__table__.create(engine) factory = sessionmaker(bind=engine) repository = TransactionalTransferAdmissionRepository(factory) task = make_task(1) service, _ = _service( admit_task=lambda item: repository.admit( storage=item.fileitem.storage, src_path=item.fileitem.path, ), enqueue=Mock(side_effect=RuntimeError("queue closed")), enqueue_failed=lambda item, error: repository.record_enqueue_failure( task_id=item.admission_task_id, error=str(error), ), ) with pytest.raises(RuntimeError, match="queue closed"): service.put(task, Mock()) with factory() as session: pending = session.execute( select(TransferPending).where( TransferPending.task_id == task.admission_task_id ) ).scalar_one() assert pending.last_error == "queue closed" engine.dispose() def test_transfer_queue_service_lists_and_removes_through_ports(): """队列查询先清理失活任务,移除操作只委托作业视图。""" service, dependencies = _service() fileitem = FileItem(storage="local", path="/tmp/demo.mkv", type="file") assert service.list() == ["job"] service.remove(fileitem) dependencies["expire_tasks"].assert_called_once_with() dependencies["list_tasks"].assert_called_once_with() dependencies["remove_task"].assert_called_once_with(fileitem) def test_do_transfer_reports_durable_admission_failure(): """背景整理准入失败必须返回批次失败,不能伪装成重复任务成功。""" chain = make_transfer_chain() fileitem = make_task(1).fileitem chain._TransferChain__get_trans_fileitems = lambda _item, **_kwargs: [ (fileitem, False) ] chain.put_to_queue = Mock(side_effect=RuntimeError("db locked")) no_history = SimpleNamespace( get_by_src=lambda _src, storage=None: None, get_success_by_src=lambda _src, storage=None: None, ) no_download = SimpleNamespace( get_by_hash=lambda _hash: None, get_file_by_fullpath=lambda _path: None, get_files_by_savepath=lambda _path: [], get_by_path=lambda _path: None, ) with patch( "app.chain.transfer.get_chain_transfer_history_port", return_value=no_history, ), patch( "app.chain.transfer.get_chain_download_history_port", return_value=no_download, ), patch( "app.chain.transfer.get_configured_system_config", return_value=SimpleNamespace(get=lambda _key: None), ): state, message = chain.do_transfer(fileitem=fileitem, background=True) assert state is False assert "加入整理队列失败:db locked" in message