"""#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"