a56b3f7b42
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 13s
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 49s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m41s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m48s
CI/CD Pipeline / Integration Tests (push) Successful in 3m54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m44s
CI/CD Pipeline / Validate - Style (push) Successful in 4m52s
CI/CD Pipeline / Unit Tests (push) Successful in 9m2s
CI/CD Pipeline / Validate - Security (push) Successful in 12m14s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Failing after 37h47m53s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 37h59m30s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 38h0m13s
CI/CD Pipeline / PR Build Web Image (push) Failing after 37h59m40s
CI/CD Pipeline / PR Build API Image (push) Failing after 37h59m40s
CI/CD Pipeline / Deploy Production (push) Failing after 37h47m15s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 37h47m20s
CI/CD Pipeline / Build Production Web Image (push) Failing after 37h47m20s
CI/CD Pipeline / Build Production API Image (push) Failing after 37h47m20s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 37h58m57s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 37h58m57s
CI/CD Pipeline / Frontend Lint (push) Failing after 37h59m37s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 37h59m41s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
235 lines
8.2 KiB
Python
235 lines
8.2 KiB
Python
"""#1749 POST /api/v1/generation/variant-plans 路由单元测试。
|
||
|
||
直接调用路由函数 + mock 依赖(不使用 TestClient,因 httpx2 冲突)。
|
||
|
||
覆盖:
|
||
- 200: 合法请求返回 items
|
||
- 400: voice_library_ids 长度不符 (schema validator)
|
||
- 400: 缺少 source_edit_plan_id 且无模板兜底 plan
|
||
- 400: 选片失败 ValueError -> HTTPException(400)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
from fastapi import HTTPException
|
||
|
||
|
||
def _call_route(request_obj, db=None):
|
||
"""直接调用 create_variant_plans 路由函数。"""
|
||
from app.api.routes.generation_variant_plans import create_variant_plans
|
||
|
||
user = MagicMock()
|
||
user.user.id = "u1"
|
||
if db is None:
|
||
db = MagicMock()
|
||
return create_variant_plans(request_obj, authenticated_user=user, db=db)
|
||
|
||
|
||
def _base_payload(**kw):
|
||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||
|
||
d = {
|
||
"template_id": "tpl_1",
|
||
"asset_ids": ["a1", "a2"],
|
||
"count": 1,
|
||
"source_edit_plan_id": "src_plan_1",
|
||
}
|
||
d.update(kw)
|
||
return VariantPlanRequest(**d)
|
||
|
||
|
||
class TestVariantPlansRoute:
|
||
def test_200_happy_path(self):
|
||
"""合法请求:ensure_variant_plans 返回 plan_id,list_clips 返回 clips。"""
|
||
req = _base_payload()
|
||
db = MagicMock()
|
||
|
||
mock_svc_inst = MagicMock()
|
||
mock_svc_inst.ensure_variant_plans.return_value = ["plan_v0"]
|
||
clip = MagicMock()
|
||
clip.id = "c1"
|
||
clip.order = 0
|
||
clip.asset_id = "a1"
|
||
clip.start_time = 0.0
|
||
clip.duration = 10.0
|
||
clip.clip_type = "main"
|
||
clip.transition_effect = "cut"
|
||
clip.transition_duration = 0.0
|
||
clip.playback_speed = 1.0
|
||
clip.text_content = ""
|
||
mock_svc_inst.list_clips.return_value = [clip]
|
||
|
||
mock_svc_cls = MagicMock(return_value=mock_svc_inst)
|
||
|
||
with (
|
||
patch(
|
||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||
return_value=["voice_1"],
|
||
),
|
||
patch(
|
||
"app.api.routes.generation_tasks._query_voice_durations",
|
||
return_value=[30.0],
|
||
),
|
||
patch(
|
||
"app.services.edit_plan_service.EditPlanService",
|
||
mock_svc_cls,
|
||
),
|
||
):
|
||
resp = _call_route(req, db)
|
||
|
||
assert resp.total == 1
|
||
assert resp.items[0].plan_id == "plan_v0"
|
||
assert len(resp.items[0].clips) == 1
|
||
|
||
def test_400_voice_library_ids_length_mismatch(self):
|
||
"""voice_library_ids 长度 != count -> pydantic model_validator 抛异常。"""
|
||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||
|
||
with pytest.raises(Exception): # noqa: B017
|
||
VariantPlanRequest(
|
||
template_id="tpl_1",
|
||
asset_ids=["a1"],
|
||
count=3,
|
||
source_edit_plan_id="src_plan_1",
|
||
voice_library_ids=["v1", "v2"], # len=2 != count=3
|
||
)
|
||
|
||
def test_400_no_source_plan_no_template_fallback(self):
|
||
"""无 source_edit_plan_id 且 DB 查不到模板 plan -> HTTPException(400)。"""
|
||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||
|
||
req = VariantPlanRequest(
|
||
template_id="tpl_nonexist",
|
||
asset_ids=[],
|
||
count=1,
|
||
source_edit_plan_id="",
|
||
)
|
||
|
||
db = MagicMock()
|
||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||
|
||
with patch(
|
||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||
return_value=[""],
|
||
):
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
_call_route(req, db)
|
||
assert exc_info.value.status_code == 400
|
||
|
||
def test_400_selection_failure_value_error(self):
|
||
"""ensure_variant_plans 抛 ValueError -> HTTPException(400)。"""
|
||
req = _base_payload()
|
||
db = MagicMock()
|
||
|
||
mock_svc_inst = MagicMock()
|
||
mock_svc_inst.ensure_variant_plans.side_effect = ValueError("素材池为空")
|
||
mock_svc_cls = MagicMock(return_value=mock_svc_inst)
|
||
|
||
with (
|
||
patch(
|
||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||
return_value=["voice_1"],
|
||
),
|
||
patch(
|
||
"app.api.routes.generation_tasks._query_voice_durations",
|
||
return_value=[30.0],
|
||
),
|
||
patch(
|
||
"app.services.edit_plan_service.EditPlanService",
|
||
mock_svc_cls,
|
||
),
|
||
):
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
_call_route(req, db)
|
||
assert exc_info.value.status_code == 400
|
||
|
||
|
||
class TestAutoCreateDefaultTemplate:
|
||
"""P0: #1911 前端删除模板选择 UI 后,后端需在无 template_id/source_edit_plan_id 时自动兜底。"""
|
||
|
||
def test_schema_allows_empty_template_and_source(self):
|
||
"""VariantPlanRequest 不再强制要求 template_id/source_edit_plan_id(后端兜底)。"""
|
||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||
|
||
req = VariantPlanRequest(asset_ids=["a1"], count=1)
|
||
assert req.template_id == ""
|
||
assert req.source_edit_plan_id == ""
|
||
|
||
def test_auto_create_default_when_no_template_no_source(self):
|
||
"""无 template_id 且无 source_edit_plan_id 时,后端自动创建默认模板并继续选片。"""
|
||
from app.api.routes.generation_variant_plans import VariantPlanRequest
|
||
|
||
req = VariantPlanRequest(asset_ids=["a1", "a2"], count=1)
|
||
db = MagicMock()
|
||
|
||
# 模拟:用户无现有模板
|
||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||
# 模拟 has_seg subquery 也返回 None
|
||
db.query.return_value.filter.return_value.first.return_value = None
|
||
|
||
mock_svc_inst = MagicMock()
|
||
mock_svc_inst.ensure_variant_plans.return_value = ["plan_v0"]
|
||
clip = MagicMock()
|
||
clip.id = "c1"
|
||
clip.order = 0
|
||
clip.asset_id = "a1"
|
||
clip.start_time = 0.0
|
||
clip.duration = 10.0
|
||
clip.clip_type = "main"
|
||
clip.transition_effect = "cut"
|
||
clip.transition_duration = 0.0
|
||
clip.playback_speed = 1.0
|
||
clip.text_content = ""
|
||
mock_svc_inst.list_clips.return_value = [clip]
|
||
|
||
created_tpl_id = "tpl_default_x"
|
||
|
||
def fake_create(cmd):
|
||
t = MagicMock()
|
||
t.id = created_tpl_id
|
||
t.user_id = cmd.user_id
|
||
t.name = cmd.name
|
||
t.mode = cmd.mode
|
||
t.segments = []
|
||
return t
|
||
|
||
fake_use_case = MagicMock()
|
||
fake_use_case.execute.side_effect = fake_create
|
||
|
||
# resolve_latest_plan_by_template 第一次调用返回 None(无历史 plan),
|
||
# 第二次因 ensure_variant_plans 已处理 source_plan_id,实际在 _get_or_create_default_template_id 后
|
||
# 调用 resolve_latest_plan_by_template → 返回 None → 走400。
|
||
# 为了让选片走通,直接patch resolve_latest_plan_by_template返回一个假plan
|
||
with (
|
||
patch(
|
||
"packages.domain.variant_voice_resolver.resolve_variant_voice_ids",
|
||
return_value=[""],
|
||
),
|
||
patch(
|
||
"app.api.routes.generation_tasks._query_voice_durations",
|
||
return_value=[0.0],
|
||
),
|
||
patch(
|
||
"app.services.edit_plan_service.EditPlanService",
|
||
MagicMock(return_value=mock_svc_inst),
|
||
),
|
||
patch(
|
||
"app.services.generation_common.resolve_latest_plan_by_template",
|
||
return_value="src_plan_new",
|
||
),
|
||
patch(
|
||
"packages.adapters.sqlalchemy_impl.template_repository.SQLAlchemyTemplateRepository",
|
||
),
|
||
patch(
|
||
"packages.application.template.use_cases.CreateTemplateUseCase",
|
||
return_value=fake_use_case,
|
||
),
|
||
):
|
||
resp = _call_route(req, db)
|
||
|
||
assert resp.total == 1
|
||
assert resp.items[0].plan_id == "plan_v0"
|