2174e91c48
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / 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 / Deploy Staging (Watchtower auto-deploy) (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 / Check if frontend-only change (pull_request) Successful in 2m39s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m43s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m8s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m30s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 8m33s
AI Code Review / AI Code Review (pull_request) Successful in 8m50s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 9m35s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
- 新增 asset_segment_tracker 服务:素材 metadata(used_time_ranges) 持久化片段级已用区间 - from-assets 创建片段时读取历史区间,新片段跨任务/跨调用自动避开 - 片段记录与 replace_all_clips_transactional 同事务,失败整体回滚 - MediaKit 异步移动片段起点后同步更新 metadata 区间记录(失败静默) - _calc_random_start_time 新增 on_exhausted 回调:100次找不到时清空该素材历史区间再重试,实现轮完一圈自动循环 详见 PR body 的 metadata schema 说明
515 lines
20 KiB
Python
515 lines
20 KiB
Python
"""测试编辑器 from-assets 端点:按模板segment创建片段 + 事务性替换 + 随机起始.
|
|
|
|
覆盖:
|
|
- 片段数量 = segment 数量(required_clips_count 被忽略)
|
|
- 素材不足时同一素材轮询切多个片段
|
|
- 随机 start_time + used_segments 去重
|
|
- 素材时长不足时 clip duration 缩短
|
|
- 素材时长为 0 时抛 400
|
|
- 使用 replace_all_clips_transactional 原子性替换
|
|
- order 从 0 开始
|
|
- start_time=None 时抛出 400
|
|
- mark_clips_ready 在事务方法内部完成
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
|
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
TEST_PLAN_ID = "plan-draft-001"
|
|
TEST_USER_ID = "user-001"
|
|
|
|
# 默认测试用 segments:4 个片段,每个 3~5 秒
|
|
DEFAULT_SEGMENTS = [(0, 3.0, 5.0), (1, 3.0, 5.0), (2, 3.0, 5.0), (3, 3.0, 5.0)]
|
|
|
|
|
|
def _segments(count: int, dur_min: float = 3.0, dur_max: float = 5.0):
|
|
return [(i, dur_min, dur_max) for i in range(count)]
|
|
|
|
|
|
def _patch_segments(segments=None):
|
|
return patch(
|
|
"app.api.routes.templates_editor.clips._get_template_segments",
|
|
return_value=segments if segments is not None else DEFAULT_SEGMENTS,
|
|
)
|
|
|
|
|
|
def _make_auth_user():
|
|
auth = MagicMock()
|
|
auth.user.id = TEST_USER_ID
|
|
auth.user.email = "test@example.com"
|
|
auth.user.display_name = "测试用户"
|
|
auth.user_id = TEST_USER_ID
|
|
return auth
|
|
|
|
|
|
def _make_mock_asset(asset_id, duration):
|
|
asset = MagicMock()
|
|
asset.id = asset_id
|
|
asset.duration = duration
|
|
return asset
|
|
|
|
|
|
def _make_plan_svc(replace_return_count=None):
|
|
svc = MagicMock()
|
|
# replace_all_clips_transactional 返回创建的片段数量
|
|
if replace_return_count is not None:
|
|
svc.replace_all_clips_transactional = MagicMock(return_value=replace_return_count)
|
|
else:
|
|
svc.replace_all_clips_transactional = MagicMock(return_value=0)
|
|
return svc
|
|
|
|
|
|
def _get_clips_data_from_call(mock_plan_svc):
|
|
"""从 replace_all_clips_transactional 的调用中获取 clips_data。"""
|
|
assert mock_plan_svc.replace_all_clips_transactional.called, "replace_all_clips_transactional 未被调用"
|
|
call_args = mock_plan_svc.replace_all_clips_transactional.call_args
|
|
# call_args = ((plan_id, clips_data), kwargs)
|
|
if len(call_args.args) >= 2:
|
|
return call_args.args[1]
|
|
return call_args.kwargs.get("clips_data", [])
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_segment_tracker():
|
|
"""from-assets 现在会读/写素材 metadata 的片段区间记录,测试中 mock 掉避免依赖真实 DB。
|
|
|
|
get_used_segments 返回空 dict(等价历史行为:无历史区间);
|
|
record/remove/reset 回调均无副作用。
|
|
"""
|
|
with (
|
|
patch(
|
|
"app.api.routes.templates_editor.clips.get_used_segments",
|
|
return_value={},
|
|
),
|
|
patch(
|
|
"app.api.routes.templates_editor.clips.record_used_segments",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"app.api.routes.templates_editor.clips.make_reset_callback",
|
|
return_value=lambda asset_id: None,
|
|
),
|
|
patch(
|
|
"app.api.routes.templates_editor.clips.remove_used_segment",
|
|
return_value=False,
|
|
),
|
|
):
|
|
yield
|
|
|
|
|
|
class TestEditorClipsBySegments:
|
|
"""测试按 segment 数量创建片段 + 素材轮询。"""
|
|
|
|
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
|
def test_creates_clips_matching_segment_count(self, mock_storage):
|
|
"""4 个 segment 即使只有2个素材也创建4个片段,required_clips_count 被忽略。"""
|
|
from app.api.routes.templates_editor.clips import (
|
|
create_clips_from_assets_editor,
|
|
)
|
|
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
|
|
|
mock_plan_svc = _make_plan_svc(replace_return_count=4)
|
|
mock_asset_repo = MagicMock()
|
|
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid]))
|
|
|
|
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)
|
|
|
|
with _patch_segments(DEFAULT_SEGMENTS):
|
|
result = create_clips_from_assets_editor(
|
|
template_id="tpl-001",
|
|
body=body,
|
|
background_tasks=MagicMock(),
|
|
plan_id=TEST_PLAN_ID,
|
|
services=(MagicMock(), mock_plan_svc),
|
|
asset_repo=mock_asset_repo,
|
|
db=MagicMock(),
|
|
current_user=_make_auth_user(),
|
|
)
|
|
|
|
assert result.created_count == 4
|
|
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
|
assert len(clips_data) == 4
|
|
|
|
# 验证轮询分配:a1, a2, a1, a2
|
|
assert clips_data[0]["asset_id"] == "a1"
|
|
assert clips_data[1]["asset_id"] == "a2"
|
|
assert clips_data[2]["asset_id"] == "a1"
|
|
assert clips_data[3]["asset_id"] == "a2"
|
|
|
|
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
|
def test_orders_start_at_zero(self, mock_storage):
|
|
"""片段 order 从 0 开始递增。"""
|
|
from app.api.routes.templates_editor.clips import (
|
|
create_clips_from_assets_editor,
|
|
)
|
|
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
|
|
|
mock_plan_svc = _make_plan_svc(replace_return_count=3)
|
|
mock_asset_repo = MagicMock()
|
|
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
|
|
|
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)
|
|
|
|
with _patch_segments(_segments(3)):
|
|
create_clips_from_assets_editor(
|
|
template_id="tpl-001",
|
|
body=body,
|
|
background_tasks=MagicMock(),
|
|
plan_id=TEST_PLAN_ID,
|
|
services=(MagicMock(), mock_plan_svc),
|
|
asset_repo=mock_asset_repo,
|
|
db=MagicMock(),
|
|
current_user=_make_auth_user(),
|
|
)
|
|
|
|
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
|
assert clips_data[0]["order"] == 0
|
|
assert clips_data[1]["order"] == 1
|
|
assert clips_data[2]["order"] == 2
|
|
|
|
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
|
def test_uses_transactional_replace(self, mock_storage):
|
|
"""使用 replace_all_clips_transactional 而不是分别 delete + create。"""
|
|
from app.api.routes.templates_editor.clips import (
|
|
create_clips_from_assets_editor,
|
|
)
|
|
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
|
|
|
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
|
mock_asset_repo = MagicMock()
|
|
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
|
|
|
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
|
|
|
with _patch_segments(_segments(2)):
|
|
create_clips_from_assets_editor(
|
|
template_id="tpl-001",
|
|
body=body,
|
|
background_tasks=MagicMock(),
|
|
plan_id=TEST_PLAN_ID,
|
|
services=(MagicMock(), mock_plan_svc),
|
|
asset_repo=mock_asset_repo,
|
|
db=MagicMock(),
|
|
current_user=_make_auth_user(),
|
|
)
|
|
|
|
# 必须调用事务方法
|
|
mock_plan_svc.replace_all_clips_transactional.assert_called_once()
|
|
# 不应调用单独的 delete 或 create
|
|
assert not hasattr(mock_plan_svc, "create_clip") or not mock_plan_svc.create_clip.called
|
|
|
|
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
|
def test_no_segments_raises_400(self, mock_storage):
|
|
"""模板没有 segment 配置时返回 400。"""
|
|
from app.api.routes.templates_editor.clips import (
|
|
create_clips_from_assets_editor,
|
|
)
|
|
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
|
|
|
mock_plan_svc = _make_plan_svc()
|
|
mock_asset_repo = MagicMock()
|
|
|
|
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
|
|
|
with _patch_segments([]):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
create_clips_from_assets_editor(
|
|
template_id="tpl-001",
|
|
body=body,
|
|
background_tasks=MagicMock(),
|
|
plan_id=TEST_PLAN_ID,
|
|
services=(MagicMock(), mock_plan_svc),
|
|
asset_repo=mock_asset_repo,
|
|
db=MagicMock(),
|
|
current_user=_make_auth_user(),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "片段配置" in exc_info.value.detail
|
|
# 不应调用替换方法
|
|
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
|
|
|
|
|
|
class TestEditorClipsDurationAndStartTime:
|
|
"""测试素材时长获取、clip duration 缩短、start_time 传入。"""
|
|
|
|
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
|
def test_clip_duration_shortened_for_short_assets(self, mock_storage):
|
|
"""素材只有 3s 时 clip duration 缩短到不超过 3.0。"""
|
|
from app.api.routes.templates_editor.clips import (
|
|
create_clips_from_assets_editor,
|
|
)
|
|
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
|
|
|
mock_plan_svc = _make_plan_svc(replace_return_count=1)
|
|
mock_asset_repo = MagicMock()
|
|
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("short", 3.0))
|
|
|
|
body = ClipsFromAssetsRequest(asset_ids=["short"])
|
|
|
|
with _patch_segments(_segments(1, dur_min=5.0, dur_max=10.0)):
|
|
create_clips_from_assets_editor(
|
|
template_id="tpl-001",
|
|
body=body,
|
|
background_tasks=MagicMock(),
|
|
plan_id=TEST_PLAN_ID,
|
|
services=(MagicMock(), mock_plan_svc),
|
|
asset_repo=mock_asset_repo,
|
|
db=MagicMock(),
|
|
current_user=_make_auth_user(),
|
|
)
|
|
|
|
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
|
assert clips_data[0]["duration"] <= 3.0
|
|
|
|
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
|
def test_zero_duration_asset_raises_400(self, mock_storage):
|
|
"""素材时长为 0 时应抛出 400,而不是创建无效片段。"""
|
|
from app.api.routes.templates_editor.clips import (
|
|
create_clips_from_assets_editor,
|
|
)
|
|
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
|
|
|
mock_plan_svc = _make_plan_svc()
|
|
mock_asset_repo = MagicMock()
|
|
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("bad", 0.0))
|
|
|
|
body = ClipsFromAssetsRequest(asset_ids=["bad"])
|
|
|
|
with _patch_segments(_segments(1)):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
create_clips_from_assets_editor(
|
|
template_id="tpl-001",
|
|
body=body,
|
|
background_tasks=MagicMock(),
|
|
plan_id=TEST_PLAN_ID,
|
|
services=(MagicMock(), mock_plan_svc),
|
|
asset_repo=mock_asset_repo,
|
|
db=MagicMock(),
|
|
current_user=_make_auth_user(),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "时长" in exc_info.value.detail
|
|
|
|
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
|
def test_missing_duration_asset_raises_400(self, mock_storage):
|
|
"""素材时长缺失(asset_repo.get 返回 None)时抛出 400。"""
|
|
from app.api.routes.templates_editor.clips import (
|
|
create_clips_from_assets_editor,
|
|
)
|
|
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
|
|
|
mock_plan_svc = _make_plan_svc()
|
|
mock_asset_repo = MagicMock()
|
|
mock_asset_repo.get = MagicMock(return_value=None)
|
|
|
|
body = ClipsFromAssetsRequest(asset_ids=["missing"])
|
|
|
|
with _patch_segments(_segments(1)):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
create_clips_from_assets_editor(
|
|
template_id="tpl-001",
|
|
body=body,
|
|
background_tasks=MagicMock(),
|
|
plan_id=TEST_PLAN_ID,
|
|
services=(MagicMock(), mock_plan_svc),
|
|
asset_repo=mock_asset_repo,
|
|
db=MagicMock(),
|
|
current_user=_make_auth_user(),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
|
|
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
|
def test_start_time_passed_to_create(self, mock_storage):
|
|
"""_calc_random_start_time 返回值被传入 clips_data。"""
|
|
from app.api.routes.templates_editor.clips import (
|
|
create_clips_from_assets_editor,
|
|
)
|
|
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
|
|
|
mock_plan_svc = _make_plan_svc(replace_return_count=2)
|
|
mock_asset_repo = MagicMock()
|
|
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0))
|
|
|
|
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)
|
|
|
|
with (
|
|
_patch_segments(_segments(2)),
|
|
patch(
|
|
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
|
side_effect=[12.5, 18.0],
|
|
) as mock_calc,
|
|
):
|
|
create_clips_from_assets_editor(
|
|
template_id="tpl-001",
|
|
body=body,
|
|
background_tasks=MagicMock(),
|
|
plan_id=TEST_PLAN_ID,
|
|
services=(MagicMock(), mock_plan_svc),
|
|
asset_repo=mock_asset_repo,
|
|
db=MagicMock(),
|
|
current_user=_make_auth_user(),
|
|
)
|
|
|
|
assert mock_calc.call_count == 2
|
|
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
|
assert clips_data[0]["start_time"] == 12.5
|
|
assert clips_data[1]["start_time"] == 18.0
|
|
|
|
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
|
def test_asset_durations_deduped(self, mock_storage):
|
|
"""asset_ids 有重复时只查询一次素材时长。"""
|
|
from app.api.routes.templates_editor.clips import (
|
|
create_clips_from_assets_editor,
|
|
)
|
|
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
|
|
|
mock_plan_svc = _make_plan_svc(replace_return_count=3)
|
|
mock_asset_repo = MagicMock()
|
|
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
|
|
|
body = ClipsFromAssetsRequest(asset_ids=["a1", "a1", "a1"])
|
|
|
|
with _patch_segments(_segments(3)):
|
|
create_clips_from_assets_editor(
|
|
template_id="tpl-001",
|
|
body=body,
|
|
background_tasks=MagicMock(),
|
|
plan_id=TEST_PLAN_ID,
|
|
services=(MagicMock(), mock_plan_svc),
|
|
asset_repo=mock_asset_repo,
|
|
db=MagicMock(),
|
|
current_user=_make_auth_user(),
|
|
)
|
|
|
|
# 去重后只调用 1 次获取素材时长
|
|
assert mock_asset_repo.get.call_count == 1
|
|
|
|
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
|
def test_used_segments_maintained_across_clips(self, mock_storage):
|
|
"""同一素材切多个片段时,used_segments 应被维护。"""
|
|
from app.api.routes.templates_editor.clips import (
|
|
create_clips_from_assets_editor,
|
|
)
|
|
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
|
|
|
mock_plan_svc = _make_plan_svc(replace_return_count=3)
|
|
mock_asset_repo = MagicMock()
|
|
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
|
|
|
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)
|
|
|
|
captured_used_segments = []
|
|
|
|
def fake_calc(asset_id, clip_duration, asset_durations, used_segments, on_exhausted=None):
|
|
captured_used_segments.append({aid: list(segs) for aid, segs in (used_segments or {}).items()})
|
|
return (len(captured_used_segments) - 1) * 5.0
|
|
|
|
with (
|
|
_patch_segments(_segments(3)),
|
|
patch(
|
|
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
|
side_effect=fake_calc,
|
|
),
|
|
):
|
|
create_clips_from_assets_editor(
|
|
template_id="tpl-001",
|
|
body=body,
|
|
background_tasks=MagicMock(),
|
|
plan_id=TEST_PLAN_ID,
|
|
services=(MagicMock(), mock_plan_svc),
|
|
asset_repo=mock_asset_repo,
|
|
db=MagicMock(),
|
|
current_user=_make_auth_user(),
|
|
)
|
|
|
|
# 第一次没有已使用时间段
|
|
assert captured_used_segments[0] == {}
|
|
# 第二次有第一次的记录
|
|
assert len(captured_used_segments[1]["a1"]) == 1
|
|
# 第三次有前两次的记录
|
|
assert len(captured_used_segments[2]["a1"]) == 2
|
|
|
|
|
|
class TestEditorClipsErrorHandling:
|
|
"""测试异常处理。"""
|
|
|
|
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
|
def test_none_start_time_raises_400(self, mock_storage):
|
|
"""_calc_random_start_time 返回 None 时应抛出 HTTPException 400。"""
|
|
from app.api.routes.templates_editor.clips import (
|
|
create_clips_from_assets_editor,
|
|
)
|
|
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
|
|
|
mock_plan_svc = _make_plan_svc()
|
|
mock_asset_repo = MagicMock()
|
|
# 素材有 duration 但 random 返回 None
|
|
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
|
|
|
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
|
|
|
with (
|
|
_patch_segments(_segments(1)),
|
|
patch(
|
|
"app.api.routes.templates_editor.clips._calc_random_start_time",
|
|
return_value=None,
|
|
),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
create_clips_from_assets_editor(
|
|
template_id="tpl-001",
|
|
body=body,
|
|
background_tasks=MagicMock(),
|
|
plan_id=TEST_PLAN_ID,
|
|
services=(MagicMock(), mock_plan_svc),
|
|
asset_repo=mock_asset_repo,
|
|
db=MagicMock(),
|
|
current_user=_make_auth_user(),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert "时长" in exc_info.value.detail
|
|
|
|
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
|
def test_transactional_replace_exception_propagates(self, mock_storage):
|
|
"""replace_all_clips_transactional 抛异常时应向上传播(事务已回滚)。"""
|
|
from app.api.routes.templates_editor.clips import (
|
|
create_clips_from_assets_editor,
|
|
)
|
|
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
|
|
|
mock_plan_svc = _make_plan_svc()
|
|
mock_plan_svc.replace_all_clips_transactional = MagicMock(side_effect=RuntimeError("DB connection lost"))
|
|
mock_asset_repo = MagicMock()
|
|
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
|
|
|
|
body = ClipsFromAssetsRequest(asset_ids=["a1"])
|
|
|
|
with _patch_segments(_segments(1)):
|
|
with pytest.raises(RuntimeError, match="DB connection lost"):
|
|
create_clips_from_assets_editor(
|
|
template_id="tpl-001",
|
|
body=body,
|
|
background_tasks=MagicMock(),
|
|
plan_id=TEST_PLAN_ID,
|
|
services=(MagicMock(), mock_plan_svc),
|
|
asset_repo=mock_asset_repo,
|
|
db=MagicMock(),
|
|
current_user=_make_auth_user(),
|
|
)
|