76e11cb2f9
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m9s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m7s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 30s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m0s
CI/CD Pipeline / Validate - Style (push) Successful in 4m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m9s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m13s
AI Code Review / AI Code Review (pull_request) Successful in 7m6s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m42s
CI/CD Pipeline / Validate - Security (push) Successful in 10m2s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m45s
CI/CD Pipeline / Unit Tests (push) Successful in 12m20s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 42m27s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
446 lines
19 KiB
Python
446 lines
19 KiB
Python
"""#2106 P0 修复单测:Seedance 对接、image_analysis 持久化、TTS Path 统一、BGM/MuseTalk 跳过。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path as _Path
|
||
|
||
# worker 容器 PYTHONPATH 包含 apps/worker(worker 侧代码使用顶层包名 services/、viral_video/)
|
||
_WORKER_ROOT = _Path(__file__).resolve().parents[2] / "apps" / "worker"
|
||
if str(_WORKER_ROOT) not in sys.path:
|
||
sys.path.insert(0, str(_WORKER_ROOT))
|
||
|
||
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from packages.domain.viral_video import ViralVideoJob, ViralVideoStatus
|
||
|
||
|
||
@pytest.fixture
|
||
def mock_job():
|
||
return ViralVideoJob(
|
||
user_id="user-001",
|
||
images=["https://img.com/1.jpg"],
|
||
industry="美妆",
|
||
duration=15,
|
||
user_copy_text="测试文案",
|
||
fusion_level="ai_polish",
|
||
)
|
||
|
||
|
||
# ── P0-2: _step_video_analysis import 路径 ──────────────────────────
|
||
|
||
|
||
class TestVideoAnalysisImport:
|
||
def test_no_reference_returns_none(self, mock_job):
|
||
from apps.worker.worker_app.tasks.viral_video import _step_video_analysis
|
||
|
||
mock_job.reference_video_url = ""
|
||
assert _step_video_analysis(mock_job) is None
|
||
|
||
def test_with_reference_returns_dict_or_none(self, mock_job):
|
||
"""有参考视频 URL 时,不管分析成功/失败/占位,返回 dict(不抛异常)。"""
|
||
from apps.worker.worker_app.tasks.viral_video import _step_video_analysis
|
||
|
||
mock_job.reference_video_url = "https://example.com/ref.mp4"
|
||
result = _step_video_analysis(mock_job)
|
||
# 允许占位/失败/真实返回,但绝不能抛异常
|
||
assert result is None or isinstance(result, dict)
|
||
|
||
|
||
# ── P0-3: image_analysis 字段 ─────────────────────────────────────
|
||
|
||
|
||
class TestImageAnalysisField:
|
||
def test_default_none(self):
|
||
job = ViralVideoJob(user_id="u1")
|
||
assert job.image_analysis is None
|
||
|
||
def test_persist_and_read(self, mock_job):
|
||
mock_job.image_analysis = {"products": [{"name": "口红"}]}
|
||
assert mock_job.image_analysis["products"][0]["name"] == "口红"
|
||
|
||
|
||
# ── P0-1: storyboard 规范化 ────────────────────────────────────────
|
||
|
||
|
||
class TestScriptGenerationV16:
|
||
"""v1.6 编导分镜脚本生成相关纯函数测试。"""
|
||
|
||
def test_fallback_script_has_required_fields(self, mock_job):
|
||
from apps.worker.worker_app.tasks.viral_video import _fallback_script
|
||
|
||
out = _fallback_script(mock_job)
|
||
assert isinstance(out, dict)
|
||
assert "overview" in out
|
||
assert "shots" in out
|
||
assert "voiceover_script" in out
|
||
assert "hard_constraints" in out
|
||
assert "negative_prompts" in out
|
||
assert out["overview"]["total_duration"] == mock_job.duration
|
||
assert out["final_copy"] == out["voiceover_script"]
|
||
assert len(out["shots"]) >= 1
|
||
|
||
def test_safe_json_loads_parses_fenced_code(self):
|
||
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
|
||
|
||
fenced = '```json\n{"voiceover_script": "你好", "shots": []}\n```'
|
||
out = _safe_json_loads(fenced)
|
||
assert out is not None
|
||
assert out["voiceover_script"] == "你好"
|
||
|
||
def test_safe_json_loads_handles_none(self):
|
||
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
|
||
|
||
assert _safe_json_loads(None) is None
|
||
assert _safe_json_loads("not json") is None
|
||
|
||
def test_validate_normalize_fills_defaults(self, mock_job):
|
||
from apps.worker.worker_app.tasks.viral_video import _validate_and_normalize_script
|
||
|
||
raw = {"voiceover_script": "你好", "shots": [{"scene_and_dialogue": "测试"}]}
|
||
out = _validate_and_normalize_script(raw, mock_job)
|
||
assert out["voiceover_script"] == "你好"
|
||
assert len(out["shots"]) == 1
|
||
assert out["shots"][0]["shot_type_angle_movement"]
|
||
assert out["overview"]["total_duration"] == mock_job.duration
|
||
|
||
def test_assemble_seedance_prompt_contains_sections(self, mock_job):
|
||
from apps.worker.worker_app.tasks.viral_video import _assemble_seedance_prompt
|
||
|
||
cr = {
|
||
"overview": {"theme": "测试", "total_duration": 15, "aspect_ratio": "9:16"},
|
||
"scene_and_lighting": "明亮",
|
||
"shots": [
|
||
{
|
||
"time_range": "0-15秒",
|
||
"shot_type_angle_movement": "中景",
|
||
"scene_and_dialogue": "你好",
|
||
"action_details": "展示",
|
||
"audio_bgm": "BGM",
|
||
"transition": "结束",
|
||
"reference_image_index": 0,
|
||
}
|
||
],
|
||
"hard_constraints": ["无字幕"],
|
||
"negative_prompts": ["水印"],
|
||
}
|
||
p = _assemble_seedance_prompt(cr, mock_job)
|
||
for key in ("【视频总览】", "【场景与光线】", "【逐镜头时间轴】", "【硬性约束】", "【负面提示词】"):
|
||
assert key in p
|
||
|
||
|
||
# ── P1: TTS 返回 Path|None ────────────────────────────────────────
|
||
|
||
|
||
class TestTTSPath:
|
||
def test_tts_returns_none_on_import_error(self, mock_job):
|
||
"""get_tts_service 抛 ImportError 时 _step_tts 返回 None。"""
|
||
from apps.worker.worker_app.tasks import viral_video as vv
|
||
|
||
with patch("apps.worker.services.tts_service_factory.get_tts_service", side_effect=ImportError("no tts")):
|
||
assert vv._step_tts(mock_job, "文案") is None
|
||
|
||
def test_tts_returns_none_when_path_not_exists(self, mock_job, tmp_path):
|
||
from apps.worker.worker_app.tasks import viral_video as vv
|
||
|
||
fake_service = MagicMock()
|
||
fake_service.synthesize.return_value = str(tmp_path / "not_exist.mp3")
|
||
with patch("apps.worker.services.tts_service_factory.get_tts_service", return_value=fake_service):
|
||
assert vv._step_tts(mock_job, "文案") is None
|
||
|
||
def test_tts_returns_path_when_exists(self, mock_job, tmp_path):
|
||
from apps.worker.worker_app.tasks import viral_video as vv
|
||
|
||
audio = tmp_path / "voice.mp3"
|
||
audio.write_bytes(b"ID3fake")
|
||
fake_service = MagicMock()
|
||
fake_service.synthesize.return_value = audio
|
||
with patch("apps.worker.services.tts_service_factory.get_tts_service", return_value=fake_service):
|
||
result = vv._step_tts(mock_job, "文案")
|
||
# Bug #2110: 校验传入了 voice_id+format=mp3
|
||
call_kwargs = fake_service.synthesize.call_args.kwargs
|
||
assert call_kwargs.get("format") == "mp3"
|
||
assert isinstance(result, Path)
|
||
assert result.exists()
|
||
|
||
|
||
# ── P1: BGM 跳过 / MuseTalk 无 persona 跳过 ───────────────────────
|
||
|
||
|
||
class TestDurationClamp:
|
||
"""v1.6 mark_copy_generated 派生字段 + duration clamp。"""
|
||
|
||
def test_mark_copy_generated_derives_fields(self):
|
||
job = ViralVideoJob(user_id="u1", duration=15)
|
||
cr = {
|
||
"overview": {"theme": "x", "total_duration": 15, "aspect_ratio": "9:16"},
|
||
"scene_and_lighting": "亮",
|
||
"shots": [{"time_range": "0-15秒", "scene_and_dialogue": "对白"}],
|
||
"voiceover_script": "你好",
|
||
"hard_constraints": [],
|
||
"negative_prompts": [],
|
||
}
|
||
job.mark_copy_generated(cr)
|
||
assert job.copy_result is cr
|
||
assert job.generated_copy_text == "你好"
|
||
assert job.storyboard == cr["shots"]
|
||
assert job.effective_copy_text == "你好"
|
||
|
||
|
||
# ── P0-1: call_video_generation 参数构造 ──────────────────────────
|
||
|
||
|
||
class TestCallVideoGeneration:
|
||
def test_returns_none_when_client_unavailable(self):
|
||
from packages.shared.ai_service import call_video_generation
|
||
|
||
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
|
||
mock_client = MagicMock()
|
||
mock_client.is_available = False
|
||
mock_get.return_value = mock_client
|
||
assert call_video_generation("prompt") is None
|
||
|
||
def test_delegates_to_client(self, tmp_path):
|
||
from packages.shared.ai_service import call_video_generation
|
||
|
||
out = tmp_path / "v.mp4"
|
||
out.write_bytes(b"fake")
|
||
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
|
||
mock_client = MagicMock()
|
||
mock_client.is_available = True
|
||
mock_client.video_generation.return_value = {
|
||
"video_path": str(out),
|
||
"usage": {"completion_tokens": 1000000},
|
||
}
|
||
mock_get.return_value = mock_client
|
||
result = call_video_generation(prompt="测试", image_url="https://img/x.jpg", duration=5, ratio="9:16")
|
||
assert result is not None and result["video_path"] == str(out)
|
||
mock_client.video_generation.assert_called_once()
|
||
kwargs = mock_client.video_generation.call_args.kwargs
|
||
assert kwargs["prompt"] == "测试"
|
||
assert kwargs["image_url"] == "https://img/x.jpg"
|
||
assert kwargs["duration"] == 5
|
||
assert kwargs["generate_audio"] is True
|
||
|
||
|
||
# ── P0-1: _step_render 占位片段生成 ──────────────────────────────
|
||
|
||
|
||
class TestCallVideoGenerationV16:
|
||
"""v1.6 call_video_generation 透传 reference_audios/reference_images 等参数到 client。"""
|
||
|
||
def test_passes_reference_params_to_client(self, tmp_path):
|
||
from packages.shared.ai_service import call_video_generation
|
||
|
||
out = tmp_path / "v.mp4"
|
||
out.write_bytes(b"fake")
|
||
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
|
||
mock_client = MagicMock()
|
||
mock_client.is_available = True
|
||
mock_client.video_generation.return_value = {
|
||
"video_path": str(out),
|
||
"usage": {"completion_tokens": 1500000},
|
||
}
|
||
mock_get.return_value = mock_client
|
||
result = call_video_generation(
|
||
prompt="测试",
|
||
image_url="https://img/x.jpg",
|
||
duration=15,
|
||
ratio="9:16",
|
||
reference_images=["https://img/r1.jpg"],
|
||
reference_audios=["https://oss/tts.mp3"],
|
||
reference_videos=["https://oss/ref.mp4"],
|
||
generate_audio=True,
|
||
model="doubao-seedance-2-5-260628",
|
||
)
|
||
assert result is not None and result["video_path"] == str(out)
|
||
kwargs = mock_client.video_generation.call_args.kwargs
|
||
# 图生视频也必须传 ratio(避免首帧方图导致默认输出 1:1)
|
||
assert kwargs.get("ratio") == "9:16", f"ratio 应透传,got {kwargs.get('ratio')!r}"
|
||
assert kwargs["image_url"] == "https://img/x.jpg"
|
||
assert kwargs["reference_audios"] == ["https://oss/tts.mp3"]
|
||
assert kwargs["reference_images"] == ["https://img/r1.jpg"]
|
||
assert kwargs["reference_videos"] == ["https://oss/ref.mp4"]
|
||
assert kwargs["generate_audio"] is True
|
||
assert kwargs["model"] == "doubao-seedance-2-5-260628"
|
||
|
||
def test_ratio_passed_when_no_image(self, tmp_path):
|
||
from packages.shared.ai_service import call_video_generation
|
||
|
||
out = tmp_path / "v.mp4"
|
||
out.write_bytes(b"fake")
|
||
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
|
||
mock_client = MagicMock()
|
||
mock_client.is_available = True
|
||
mock_client.video_generation.return_value = {"video_path": str(out), "usage": None}
|
||
mock_get.return_value = mock_client
|
||
call_video_generation(prompt="测试", duration=10, ratio="16:9")
|
||
kwargs = mock_client.video_generation.call_args.kwargs
|
||
assert kwargs["ratio"] == "16:9"
|
||
assert kwargs["image_url"] is None
|
||
|
||
|
||
# ── P0-1: DoubaoClient.video_generation 在不可用时返回 None ───────
|
||
|
||
|
||
class TestDoubaoClientVideoGen:
|
||
def test_unavailable_returns_none(self):
|
||
from packages.shared.ai_client import DoubaoClient
|
||
|
||
client = DoubaoClient.__new__(DoubaoClient)
|
||
client.api_key = "" # is_available -> False
|
||
assert client.video_generation("prompt") is None
|
||
|
||
|
||
# ── P0-3: resume 从 job 读 image_analysis ────────────────────────
|
||
|
||
|
||
class TestResumeReadsImageAnalysis:
|
||
def test_resume_uses_persisted_image_analysis(self):
|
||
"""resume/render pipeline 应从 job.image_analysis 读(v1.5 _run_render_pipeline 共享渲染逻辑)。"""
|
||
import inspect
|
||
|
||
from apps.worker.worker_app.tasks import viral_video as vv
|
||
|
||
# v1.5 改造后 resume 委托给 _run_render_pipeline,那里读取 job.image_analysis
|
||
src = inspect.getsource(vv._run_render_pipeline)
|
||
assert "job.image_analysis" in src
|
||
assert "image_analysis" in src
|
||
# resume 本身应该调用 _run_render_pipeline
|
||
resume_src = inspect.getsource(vv.resume_viral_video_pipeline)
|
||
assert "_run_render_pipeline" in resume_src
|
||
|
||
|
||
# ============ #2157 _replace_henjin_everywhere 递归替换单测 ============
|
||
|
||
|
||
class TestReplaceHenjinEverywhere:
|
||
"""覆盖 #2157 P1:递归替换 copy_result 中所有层级的'很近'→'最近'。"""
|
||
|
||
def _import(self):
|
||
from apps.worker.worker_app.tasks.viral_video import _replace_henjin_everywhere
|
||
|
||
return _replace_henjin_everywhere
|
||
|
||
def test_plain_string_no_henjin(self):
|
||
fn = self._import()
|
||
assert fn("最近好物推荐") == "最近好物推荐"
|
||
assert fn("") == ""
|
||
assert fn(None) is None
|
||
assert fn(123) == 123
|
||
|
||
def test_string_with_henjin(self):
|
||
fn = self._import()
|
||
assert fn("很近是不是总觉得颈肩发僵") == "最近是不是总觉得颈肩发僵"
|
||
# 多次出现
|
||
assert fn("很近很近都很近") == "最近最近都最近"
|
||
|
||
def test_list_recursive(self):
|
||
fn = self._import()
|
||
out = fn(["很近a", "b", ["很近c", "d"]])
|
||
assert out == ["最近a", "b", ["最近c", "d"]]
|
||
|
||
def test_dict_recursive_nested(self):
|
||
fn = self._import()
|
||
obj = {
|
||
"overview": {"theme": "很近颈肩", "title": "x"},
|
||
"scene_and_lighting": "很近才好用",
|
||
"voiceover_script": "很近是不是",
|
||
"final_copy": "很近好物",
|
||
"shots": [
|
||
{"scene_and_dialogue": "很近第一镜", "action_details": "很近动作", "audio_bgm": "很近音乐"},
|
||
{"nested": {"deep": "很近深层"}},
|
||
],
|
||
"int_field": 42,
|
||
}
|
||
import json
|
||
|
||
out = fn(obj)
|
||
assert "很近" not in json.dumps(out, ensure_ascii=False)
|
||
assert out["overview"]["theme"] == "最近颈肩"
|
||
assert out["shots"][0]["scene_and_dialogue"] == "最近第一镜"
|
||
assert out["shots"][1]["nested"]["deep"] == "最近深层"
|
||
assert out["int_field"] == 42
|
||
|
||
|
||
class TestMarkFailedAndNotifySessionFallback:
|
||
"""#2157 P1:_mark_failed_and_notify 在原session失效时fallback到新SessionLocal。"""
|
||
|
||
def test_fallback_to_new_session_when_original_save_raises(self, tmp_path):
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from apps.worker.worker_app.tasks import viral_video as vv
|
||
|
||
job = MagicMock()
|
||
job.is_terminal = False
|
||
job.mark_failed = MagicMock()
|
||
|
||
# 原 session 保存抛异常
|
||
orig_session = MagicMock()
|
||
orig_repo = MagicMock()
|
||
|
||
def _raise(*a, **kw):
|
||
raise RuntimeError("session in rollback")
|
||
|
||
# 第一次调用_save_job抛异常,触发fallback
|
||
with patch.object(vv, "_save_job", side_effect=_raise):
|
||
fake_ssn = MagicMock()
|
||
fake_repo = MagicMock()
|
||
fake_job_in_db = MagicMock()
|
||
fake_job_in_db.is_terminal = False
|
||
fake_repo.get.return_value = fake_job_in_db
|
||
with patch.object(vv, "SessionLocal", return_value=fake_ssn):
|
||
with patch.object(vv, "SQLAlchemyViralVideoJobRepository", return_value=fake_repo):
|
||
with patch.object(vv, "_emit_progress") as mock_emit:
|
||
vv._mark_failed_and_notify("job-1", orig_session, orig_repo, job, "boom", stage="render")
|
||
# 原session上mark_failed被调用过
|
||
job.mark_failed.assert_called()
|
||
# fallback路径:新session上repo.get(job-1)被调用,且新job被mark_failed并commit
|
||
fake_repo.get.assert_called_with("job-1")
|
||
fake_job_in_db.mark_failed.assert_called_with("boom")
|
||
fake_repo.update.assert_called_with(fake_job_in_db)
|
||
fake_ssn.commit.assert_called()
|
||
fake_ssn.close.assert_called()
|
||
mock_emit.assert_called_once()
|
||
|
||
def test_original_session_happy_path_no_fallback(self):
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from apps.worker.worker_app.tasks import viral_video as vv
|
||
|
||
job = MagicMock()
|
||
job.is_terminal = False
|
||
session = MagicMock()
|
||
repo = MagicMock()
|
||
with patch.object(vv, "_save_job") as mock_save:
|
||
with patch.object(vv, "_emit_progress") as mock_emit:
|
||
# 不mock SessionLocal,如果fallback被错误触发会抛AttributeError
|
||
vv._mark_failed_and_notify("job-2", session, repo, job, "err", stage="copy")
|
||
job.mark_failed.assert_called_with("err")
|
||
mock_save.assert_called()
|
||
mock_emit.assert_called_once()
|
||
|
||
def test_terminal_job_not_marked(self):
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from apps.worker.worker_app.tasks import viral_video as vv
|
||
|
||
job = MagicMock()
|
||
job.is_terminal = True # 已终态
|
||
session = MagicMock()
|
||
repo = MagicMock()
|
||
with patch.object(vv, "_save_job") as mock_save:
|
||
with patch.object(vv, "_emit_progress"):
|
||
fake_ssn = MagicMock()
|
||
with patch.object(vv, "SessionLocal", return_value=fake_ssn):
|
||
with patch.object(vv, "SQLAlchemyViralVideoJobRepository") as mock_repo_cls:
|
||
vv._mark_failed_and_notify("job-3", session, repo, job, "x")
|
||
# 终态job不调用mark_failed
|
||
job.mark_failed.assert_not_called()
|
||
# 且因 job 已终态,_save_job 也不应被调用(marked=False 才fallback;但此处 job 非 None 且 is_terminal=True,marked 保持 False 进入 fallback)
|
||
# fallback路径会重新打开session,get到的job也是终态,不会update
|