"""P0/P1 修复单元测试 — 一键生成 P0 问题 + P1 校验. 覆盖: P0-1: _download_library_assets 双模式查询(asset_library_id / project_id) P0-2: OSS 上传失败抛异常 + URL 可访问性校验 P0-3: FFmpeg 失败时完整 stderr 日志 P1: template_id 存在性校验 + asset_ids 归属校验 """ from __future__ import annotations import logging import subprocess import sys from pathlib import Path from unittest.mock import MagicMock, patch import pytest # 添加 worker app 到 sys.path _WORKER_ROOT = Path(__file__).resolve().parents[2] / "apps" / "worker" if str(_WORKER_ROOT) not in sys.path: sys.path.insert(0, str(_WORKER_ROOT)) # ── P0-1: _download_library_assets ──────────────────────────────────────────── class TestDownloadLibraryAssets: """P0-1: 素材下载双模式 + 错误处理.""" def _make_asset(self, id_: str, file_url: str, project_id: str = "p1", library_id: str = "lib1"): mock = MagicMock() mock.id = id_ mock.file_url = file_url mock.name = f"asset_{id_}" mock.project_id = project_id mock.asset_library_id = library_id return mock @patch("worker_app.tasks.generation.SessionLocal") @patch("worker_app.tasks.generation.download_asset") def test_asset_library_mode(self, mock_download, mock_session_factory): """素材库模式:按 asset_library_id 查询.""" from worker_app.tasks.generation import _download_library_assets session = MagicMock() mock_session_factory.return_value = session query = MagicMock() session.query.return_value = query filter_result = MagicMock() query.filter.return_value = filter_result in_filter = MagicMock() filter_result.filter.return_value = in_filter assets = [self._make_asset("a1", "video/a1.mp4")] in_filter.order_by.return_value.all.return_value = assets mock_download.return_value = True with patch("worker_app.tasks.generation.AssetModel", create=True): result = _download_library_assets( Path("/tmp/test"), asset_library_id="lib1", ) assert len(result) == 1 mock_download.assert_called_once() @patch("worker_app.tasks.generation.SessionLocal") @patch("worker_app.tasks.generation.download_asset") def test_project_mode(self, mock_download, mock_session_factory): """项目级模式:asset_library_id 为空时按 project_id 查询.""" from worker_app.tasks.generation import _download_library_assets session = MagicMock() mock_session_factory.return_value = session query = MagicMock() session.query.return_value = query filter_result = MagicMock() query.filter.return_value = filter_result proj_filter = MagicMock() filter_result.filter.return_value = proj_filter assets = [self._make_asset("a1", "video/a1.mp4", project_id="proj1")] proj_filter.order_by.return_value.all.return_value = assets mock_download.return_value = True result = _download_library_assets( Path("/tmp/test"), project_id="proj1", ) assert len(result) == 1 def test_both_empty_raises(self): """asset_library_id 和 project_id 都为空时抛 ValueError.""" from worker_app.tasks.generation import _download_library_assets with pytest.raises(ValueError, match="至少需要提供一个"): _download_library_assets(Path("/tmp/test")) @patch("worker_app.tasks.generation.SessionLocal") def test_no_assets_found_raises(self, mock_session_factory): """查不到素材时抛 RuntimeError.""" from worker_app.tasks.generation import _download_library_assets session = MagicMock() mock_session_factory.return_value = session query = MagicMock() session.query.return_value = query filter_result = MagicMock() query.filter.return_value = filter_result in_filter = MagicMock() filter_result.filter.return_value = in_filter in_filter.order_by.return_value.all.return_value = [] with pytest.raises(RuntimeError, match="未找到视频素材"): _download_library_assets( Path("/tmp/test"), asset_library_id="lib1", ) @patch("worker_app.tasks.generation.SessionLocal") @patch("worker_app.tasks.generation.download_asset") def test_all_asset_ids_fail_raises(self, mock_download, mock_session_factory): """指定 asset_ids 但全部下载失败时抛 RuntimeError.""" from worker_app.tasks.generation import _download_library_assets session = MagicMock() mock_session_factory.return_value = session query = MagicMock() session.query.return_value = query filter_result = MagicMock() query.filter.return_value = filter_result id_filter = MagicMock() filter_result.filter.return_value = id_filter assets = [self._make_asset("a1", "video/a1.mp4")] id_filter.order_by.return_value.all.return_value = assets mock_download.return_value = False # 全部下载失败 with pytest.raises(RuntimeError, match="素材下载失败"): _download_library_assets( Path("/tmp/test"), asset_library_id="lib1", asset_ids=["a1"], ) # ── P0-2: OSS 上传 + URL 校验 ──────────────────────────────────────────────── class TestOSSUploadAndVerify: """P0-2: OSS 上传失败抛异常 + URL 可访问性校验.""" def test_verify_url_accessible_success(self): """URL 可访问时返回 True.""" from worker_app.tasks.generation import _verify_url_accessible mock_response = MagicMock() mock_response.status = 200 mock_response.__enter__ = MagicMock(return_value=mock_response) mock_response.__exit__ = MagicMock(return_value=False) with patch("urllib.request.OpenerDirector.open", return_value=mock_response): assert _verify_url_accessible("https://example.com/test.mp4") is True def test_verify_url_accessible_failure(self): """URL 不可访问时返回 False.""" from worker_app.tasks.generation import _verify_url_accessible with patch("urllib.request.OpenerDirector.open", side_effect=Exception("connection refused")): assert _verify_url_accessible("https://example.com/test.mp4") is False def test_verify_url_404(self): """URL 返回 404 时返回 False.""" from worker_app.tasks.generation import _verify_url_accessible mock_response = MagicMock() mock_response.status = 404 mock_response.__enter__ = MagicMock(return_value=mock_response) mock_response.__exit__ = MagicMock(return_value=False) with patch("urllib.request.OpenerDirector.open", return_value=mock_response): assert _verify_url_accessible("https://example.com/test.mp4") is False # ── P0-3: FFmpeg stderr 日志 ───────────────────────────────────────────────── class TestFFmpegStderrLogging: """P0-3: FFmpeg 失败时完整 stderr 打到日志.""" def test_run_ffmpeg_logs_stderr_on_failure(self, caplog): """run_ffmpeg 失败时记录 stderr 到日志.""" from video_processing.ffmpeg_utils import run_ffmpeg error = subprocess.CalledProcessError( returncode=183, cmd=["ffmpeg", "-y", "-i", "input.mp4", "output.mp4"], output="", stderr="Error message from ffmpeg: filter graph error details here", ) with patch("subprocess.run", side_effect=error): with caplog.at_level(logging.ERROR): with pytest.raises(subprocess.CalledProcessError): run_ffmpeg(["ffmpeg", "-y", "-i", "input.mp4", "output.mp4"]) assert "FFmpeg 命令失败" in caplog.text assert "exit_code=183" in caplog.text assert "filter graph error" in caplog.text def test_run_ffmpeg_success(self): """run_ffmpeg 成功时正常返回.""" from video_processing.ffmpeg_utils import run_ffmpeg mock_result = MagicMock() mock_result.stdout = "output" mock_result.stderr = "" with patch("subprocess.run", return_value=mock_result): stdout, stderr = run_ffmpeg(["ffmpeg", "-version"]) assert stdout == "output" assert stderr == "" # ── P1: template_id + asset_ids 校验 ───────────────────────────────────────── class TestP1Validations: """P1: template_id 存在性校验 + asset_ids 归属校验.""" def test_validate_template_exists_success(self): """模板存在时不抛异常.""" from worker_app.tasks.generation import _validate_template_exists mock_template = MagicMock() mock_template.id = "tmpl_001" mock_template.name = "Test Template" mock_template.is_active = True session = MagicMock() mock_session = MagicMock() session.query.return_value = mock_session filter_result = MagicMock() mock_session.filter.return_value = filter_result filter_result.first.return_value = mock_template with patch("worker_app.tasks.generation.SessionLocal", return_value=session): _validate_template_exists("tmpl_001") # 不抛异常 def test_validate_template_exists_not_found(self): """模板不存在时抛 ValueError.""" from worker_app.tasks.generation import _validate_template_exists session = MagicMock() mock_session = MagicMock() session.query.return_value = mock_session filter_result = MagicMock() mock_session.filter.return_value = filter_result filter_result.first.return_value = None with patch("worker_app.tasks.generation.SessionLocal", return_value=session): with pytest.raises(ValueError, match="模板不存在"): _validate_template_exists("tmpl_nonexistent")