b7a0d29b0d
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 42s
CI/CD Pipeline / Unit Tests (push) Successful in 3m4s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m7s
CI/CD Pipeline / Integration Tests (push) Successful in 1m37s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (push) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
411 lines
16 KiB
Python
Executable File
411 lines
16 KiB
Python
Executable File
"""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")
|
||
|
||
|
||
# ── P1: 一键生成 clip 级效果层映射 ───────────────────────────────────────────
|
||
|
||
|
||
class TestTemplateClipEffectMapping:
|
||
"""P1: 模板 clip 级效果层映射到一键生成素材 clips."""
|
||
|
||
def _make_virtual_clip(self, idx: int, clip_type: str = "main", config: dict | None = None):
|
||
from dataclasses import dataclass, field
|
||
|
||
_clip_type_val = clip_type
|
||
|
||
@dataclass
|
||
class FakeClip:
|
||
id: str = f"vc_{idx:03d}"
|
||
plan_id: str = "task_001"
|
||
clip_type: str = _clip_type_val
|
||
order: int = idx
|
||
asset_id: str = f"asset_{idx}"
|
||
duration: float = 5.0
|
||
transition_effect: str = "cut"
|
||
config: dict = field(default_factory=dict)
|
||
|
||
return FakeClip(config=config or {})
|
||
|
||
def _make_template_clip_config(self, clip_type: str = "main", transition: str = "cut", config: dict | None = None):
|
||
mock = MagicMock()
|
||
mock.clip_type = clip_type
|
||
mock.transition_effect = transition
|
||
mock.config = config or {}
|
||
mock.default_duration = 3.0
|
||
mock.text_template = ""
|
||
return mock
|
||
|
||
def test_transition_effect_mapped(self):
|
||
"""转场效果正确映射到素材 clips."""
|
||
from worker_app.tasks.generation import _apply_template_clip_effects
|
||
|
||
clips = [self._make_virtual_clip(i) for i in range(3)]
|
||
clip_configs = [
|
||
self._make_template_clip_config("main", transition="fade"),
|
||
self._make_template_clip_config("main", transition="dissolve"),
|
||
]
|
||
|
||
_apply_template_clip_effects(clips, clip_configs, "one_take")
|
||
|
||
# 前两个按顺序映射,第三个用最后一个模板配置
|
||
assert clips[0].transition_effect == "fade"
|
||
assert clips[1].transition_effect == "dissolve"
|
||
assert clips[2].transition_effect == "dissolve" # 复用最后一个
|
||
|
||
def test_color_grade_mapped(self):
|
||
"""滤镜配置正确映射到 clip.config.color_grade."""
|
||
from worker_app.tasks.generation import _apply_template_clip_effects
|
||
|
||
clips = [self._make_virtual_clip(i) for i in range(2)]
|
||
clip_configs = [
|
||
self._make_template_clip_config(
|
||
"main", config={"color_grade": {"enabled": True, "filter": "vintage", "brightness": 0.1}}
|
||
),
|
||
]
|
||
|
||
_apply_template_clip_effects(clips, clip_configs, "one_take")
|
||
|
||
assert clips[0].config["color_grade"]["filter"] == "vintage"
|
||
assert clips[0].config["color_grade"]["brightness"] == 0.1
|
||
# 第二个素材复用第一个模板配置
|
||
assert clips[1].config["color_grade"]["filter"] == "vintage"
|
||
|
||
def test_existing_config_preserved(self):
|
||
"""已有 clip.config 内容(如 role)被保留."""
|
||
from worker_app.tasks.generation import _apply_template_clip_effects
|
||
|
||
clips = [self._make_virtual_clip(0, config={"role": "b_roll"})]
|
||
clip_configs = [
|
||
self._make_template_clip_config("main", config={"color_grade": {"enabled": True, "filter": "warm"}}),
|
||
]
|
||
|
||
_apply_template_clip_effects(clips, clip_configs, "voice_over")
|
||
|
||
assert clips[0].config["role"] == "b_roll" # 保留原有配置
|
||
assert clips[0].config["color_grade"]["filter"] == "warm" # 新增滤镜配置
|
||
|
||
def test_empty_clip_configs_no_change(self):
|
||
"""空模板配置时 clips 保持不变."""
|
||
from worker_app.tasks.generation import _apply_template_clip_effects
|
||
|
||
clips = [self._make_virtual_clip(i) for i in range(2)]
|
||
_apply_template_clip_effects(clips, [], "one_take")
|
||
|
||
assert clips[0].transition_effect == "cut"
|
||
assert clips[1].transition_effect == "cut"
|
||
|
||
def test_cut_transition_not_overwritten(self):
|
||
"""模板转场为 cut 时不覆盖(保持默认)."""
|
||
from worker_app.tasks.generation import _apply_template_clip_effects
|
||
|
||
clips = [self._make_virtual_clip(0)]
|
||
clips[0].transition_effect = "fade" # 已有非默认值
|
||
clip_configs = [
|
||
self._make_template_clip_config("main", transition="cut"),
|
||
]
|
||
|
||
_apply_template_clip_effects(clips, clip_configs, "one_take")
|
||
|
||
# 模板是 cut 时,保留原有值(避免无意义覆盖)
|
||
assert clips[0].transition_effect == "fade"
|
||
|
||
def test_intro_outro_extracted(self):
|
||
"""intro/outro 类型 clip_config 正确提取为 plan 级 intro_outro 配置."""
|
||
from worker_app.tasks.generation import _extract_intro_outro_from_clip_configs
|
||
|
||
clip_configs = [
|
||
self._make_template_clip_config("intro", config={"intro_type": "text", "intro_text_color": "#ffffff"}),
|
||
self._make_template_clip_config("main"),
|
||
self._make_template_clip_config("outro", config={"outro_type": "follow", "outro_follow_text": "关注我们"}),
|
||
]
|
||
# 设置 intro/outro 的 text_template
|
||
clip_configs[0].text_template = "精彩视频"
|
||
clip_configs[0].default_duration = 2.5
|
||
|
||
result = _extract_intro_outro_from_clip_configs(clip_configs)
|
||
|
||
assert result["has_intro"] is True
|
||
assert result["intro_type"] == "text"
|
||
assert result["intro_text"] == "精彩视频"
|
||
assert result["intro_duration"] == 2.5
|
||
assert result["intro_text_color"] == "#ffffff"
|
||
assert result["has_outro"] is True
|
||
assert result["outro_type"] == "follow"
|
||
assert result["outro_follow_text"] == "关注我们"
|
||
|
||
def test_intro_outro_empty_when_none(self):
|
||
"""没有 intro/outro 时返回空 dict."""
|
||
from worker_app.tasks.generation import _extract_intro_outro_from_clip_configs
|
||
|
||
clip_configs = [
|
||
self._make_template_clip_config("main"),
|
||
self._make_template_clip_config("main"),
|
||
]
|
||
|
||
result = _extract_intro_outro_from_clip_configs(clip_configs)
|
||
assert result == {}
|