Files
xiaoxia-saas/tests/unit/test_config_schemas_and_ai_endpoints.py
T
CI Bot 6cbd08f666
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m30s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m44s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m5s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m28s
fix(code-quality): 第一批代码质量修复 - 零风险项
- 修复 ruff 配置:移除已废弃规则 W503/E704(ruff 0.14+ 不兼容)
- 修复 F541 (13处):f-string 无占位符改为普通字符串
- 修复 B017 (1处):pytest.raises(Exception) 改为 ValidationError
- 修复 vulture 死代码:trim_engine 中 if False 的三元表达式
- 修复 mypy var-annotated:SUNSET_VERSIONS 加类型标注
2026-07-15 00:51:36 +08:00

541 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""config_schemas + AI 推荐/封面生成 单元测试.
覆盖(20+ 测试用例):
- config_schemas: normalize_plan_config / normalize_template_config 默认值填充、部分覆盖、非标准字段保留
- config_schemas: Pydantic 枚举校验(CoverType / TextPosition / BGMSource
- ai_tasks: run_ai_recommend / run_generate_cover stub 返回结构
- edit_plans API: POST /{plan_id}/ai-recommend 正常/404/400
- edit_plans API: POST /{plan_id}/generate-cover 正常/404
- edit_plans API: create_plan config 标准化
- edit_plans API: update_plan config 标准化
- edit_templates API: create_template / update_template config 标准化
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
# ---------------------------------------------------------------------------
# config_schemas 单元测试
# ---------------------------------------------------------------------------
class TestNormalizePlanConfig:
def test_none_returns_full_defaults(self):
from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG, normalize_plan_config
result = normalize_plan_config(None)
assert result == DEFAULT_EDIT_PLAN_CONFIG.copy()
def test_empty_dict_returns_full_defaults(self):
from packages.domain.config_schemas import normalize_plan_config
result = normalize_plan_config({})
assert result["cover"]["type"] == "ai_frame"
assert result["title"]["enabled"] is True
assert result["subtitle"]["position"] == "bottom"
assert result["bgm"]["volume"] == 0.3
def test_partial_cover_override(self):
from packages.domain.config_schemas import normalize_plan_config
result = normalize_plan_config({"cover": {"type": "upload", "image_url": "https://example.com/cover.jpg"}})
assert result["cover"]["type"] == "upload"
assert result["cover"]["image_url"] == "https://example.com/cover.jpg"
# frame_time 保留默认值 None
assert result["cover"]["frame_time"] is None
def test_partial_title_override(self):
from packages.domain.config_schemas import normalize_plan_config
result = normalize_plan_config({"title": {"text": "我的标题", "ai_auto": False}})
assert result["title"]["text"] == "我的标题"
assert result["title"]["ai_auto"] is False
# 其他字段保留默认值
assert result["title"]["font"] == "思源黑体"
assert result["title"]["size"] == 48
def test_non_standard_fields_preserved(self):
from packages.domain.config_schemas import normalize_plan_config
result = normalize_plan_config({"generation_task_id": "task-123", "custom_key": "value"})
assert result["generation_task_id"] == "task-123"
assert result["custom_key"] == "value"
# 标准字段仍然完整
assert "cover" in result
assert "bgm" in result
def test_bgm_partial_override(self):
from packages.domain.config_schemas import normalize_plan_config
result = normalize_plan_config({"bgm": {"volume": 0.8}})
assert result["bgm"]["volume"] == 0.8
assert result["bgm"]["source"] == "library"
assert result["bgm"]["asset_id"] == ""
def test_multiple_sections_override(self):
from packages.domain.config_schemas import normalize_plan_config
result = normalize_plan_config(
{
"cover": {"type": "manual", "frame_time": 5.0},
"title": {"enabled": False},
"subtitle": {"size": 36},
"bgm": {"source": "ai_recommend"},
}
)
assert result["cover"]["type"] == "manual"
assert result["cover"]["frame_time"] == 5.0
assert result["title"]["enabled"] is False
assert result["subtitle"]["size"] == 36
assert result["bgm"]["source"] == "ai_recommend"
class TestNormalizeTemplateConfig:
def test_same_as_plan_config_plus_template_fields(self):
"""template config 包含 plan config 的所有字段,外加 transition_enabled"""
from packages.domain.config_schemas import normalize_plan_config, normalize_template_config
raw = {"title": {"text": "模板标题"}}
plan_cfg = normalize_plan_config(raw)
tpl_cfg = normalize_template_config(raw)
# plan config 的字段在 template config 中应一致
for key in plan_cfg:
assert tpl_cfg[key] == plan_cfg[key]
# template config 额外包含 transition_enabled
assert "transition_enabled" in tpl_cfg
assert tpl_cfg["transition_enabled"] is True
def test_none_returns_defaults(self):
from packages.domain.config_schemas import DEFAULT_EDIT_TEMPLATE_CONFIG, normalize_template_config
result = normalize_template_config(None)
assert result == DEFAULT_EDIT_TEMPLATE_CONFIG.copy()
class TestConfigSchemas:
def test_cover_type_enum_values(self):
from packages.domain.config_schemas import CoverType
assert CoverType.AI_FRAME.value == "ai_frame"
assert CoverType.MANUAL.value == "manual"
assert CoverType.UPLOAD.value == "upload"
assert CoverType.AI_REGENERATE.value == "ai_regenerate"
def test_text_position_enum_values(self):
from packages.domain.config_schemas import TextPosition
assert TextPosition.TOP.value == "top"
assert TextPosition.CENTER.value == "center"
assert TextPosition.BOTTOM.value == "bottom"
def test_bgm_source_enum_values(self):
from packages.domain.config_schemas import BGMSource
assert BGMSource.LIBRARY.value == "library"
assert BGMSource.UPLOAD.value == "upload"
assert BGMSource.AI_RECOMMEND.value == "ai_recommend"
def test_cover_config_model(self):
from packages.domain.config_schemas import CoverConfig, CoverType
cfg = CoverConfig(type=CoverType.MANUAL, frame_time=3.5)
assert cfg.type == CoverType.MANUAL
assert cfg.frame_time == 3.5
assert cfg.image_url == ""
def test_title_config_defaults(self):
from packages.domain.config_schemas import TitleConfig
cfg = TitleConfig()
assert cfg.enabled is True
assert cfg.ai_auto is True
assert cfg.font == "思源黑体"
assert cfg.size == 48
assert cfg.bold is True
assert cfg.stroke.enabled is False
def test_bgm_config_validation(self):
from packages.domain.config_schemas import BGMConfig
cfg = BGMConfig(volume=0.5)
assert cfg.volume == 0.5
from pydantic import ValidationError
with pytest.raises(ValidationError):
BGMConfig(volume=1.5) # > 1.0 应该校验失败
def test_edit_plan_config_schema_full(self):
from packages.domain.config_schemas import EditPlanConfigSchema
schema = EditPlanConfigSchema()
assert schema.cover.type.value == "ai_frame"
assert schema.title.enabled is True
assert schema.subtitle.position.value == "bottom"
assert schema.bgm.source.value == "library"
# ---------------------------------------------------------------------------
# ai_tasks 单元测试
# ---------------------------------------------------------------------------
class TestAIRunTasks:
def test_run_ai_recommend_returns_expected_structure(self):
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
result = run_ai_recommend(
plan_id="plan-001",
template_id="tpl-001",
asset_ids=["asset-1", "asset-2"],
editing_mode="one_take",
target_duration=30.0,
)
assert "clips" in result
assert "config" in result
assert "total_duration" in result
assert "confidence" in result
assert len(result["clips"]) >= 2 # 至少 intro + outro
assert result["total_duration"] > 0
assert 0 <= result["confidence"] <= 1
def test_run_ai_recommend_clip_structure(self):
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
result = run_ai_recommend(
plan_id="plan-001",
template_id="tpl-001",
asset_ids=["asset-1"],
)
for clip in result["clips"]:
assert "clip_type" in clip
assert "order" in clip
assert "duration" in clip
assert clip["duration"] > 0
def test_run_ai_recommend_empty_assets(self):
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
result = run_ai_recommend(
plan_id="plan-001",
template_id="tpl-001",
asset_ids=[],
)
# 即使没有素材,也应该有 intro + outro
assert len(result["clips"]) >= 2
def test_run_generate_cover_ai_frame(self):
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
result = run_generate_cover(
plan_id="plan-001",
asset_ids=["asset-1"],
cover_type="ai_frame",
)
assert result["type"] == "ai_frame"
assert "image_url" in result
assert "frame_time" in result
assert "confidence" in result
def test_run_generate_cover_manual(self):
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
result = run_generate_cover(
plan_id="plan-001",
asset_ids=["asset-1"],
cover_type="manual",
frame_time=5.0,
)
assert result["type"] == "manual"
assert result["frame_time"] == 5.0
def test_run_generate_cover_upload(self):
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
result = run_generate_cover(
plan_id="plan-001",
asset_ids=[],
cover_type="upload",
)
assert result["type"] == "upload"
# ---------------------------------------------------------------------------
# API 端点测试 — AI 推荐 & 封面生成
# ---------------------------------------------------------------------------
class StubEditPlanRepository:
"""内存中的 EditPlan 仓储 stub(支持 clips"""
def __init__(self):
self._plans: dict[str, Any] = {}
self._clips: dict[str, list] = {} # plan_id → [clip]
self._counter = 0
def _next_id(self) -> str:
self._counter += 1
return f"plan-{self._counter:03d}"
def get(self, plan_id: str):
return self._plans.get(plan_id)
def create(self, plan):
self._plans[plan.id] = plan
return plan
def update(self, plan):
if plan.id not in self._plans:
raise ValueError(f"EditPlan {plan.id} not found")
self._plans[plan.id] = plan
return plan
def delete(self, plan_id: str):
if plan_id not in self._plans:
return False
del self._plans[plan_id]
return True
def list_all(self, *, status=None, skip=0, limit=50):
items = list(self._plans.values())
if status:
items = [p for p in items if p.status == status]
return items[skip : skip + limit]
def count(self, *, template_id=None, status=None):
return len(list(self._plans.values()))
def delete_by_plan(self, plan_id: str):
self._clips.pop(plan_id, None)
def _make_auth_user():
from app.auth import AuthenticatedUser
from packages.domain.entities import User
user = User(id="user-001", email="test@example.com", display_name="测试用户")
return AuthenticatedUser(user=user)
def _create_ai_test_app():
"""创建带 stub 注入的测试 FastAPI 应用(支持 AI 端点)"""
import app.services.edit_plan_service as service_module
from app.api.routes import edit_plans as edit_plans_module
from app.api.routes.edit_plans import router
stub_repo = StubEditPlanRepository()
# Mock service methods that interact with DB
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_repo
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_repo
service_module.SQLAlchemyGenerationTaskRepository = lambda db: stub_repo
app = FastAPI()
app.include_router(router, prefix="/api/v1/edit-plans")
app.dependency_overrides[edit_plans_module.get_current_user] = _make_auth_user
app.dependency_overrides[edit_plans_module.get_db_session] = lambda: MagicMock()
def cleanup():
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
return app, stub_repo, cleanup
from fastapi import FastAPI
from fastapi.testclient import TestClient
from packages.domain.edit_plan import EditPlan
@pytest.fixture
def ai_client():
app, stub_repo, cleanup = _create_ai_test_app()
yield TestClient(app), stub_repo
cleanup()
class TestAIRecommendEndpoint:
def test_ai_recommend_success(self, ai_client):
c, repo = ai_client
plan = EditPlan.create("tpl-001", "测试计划")
repo.create(plan)
resp = c.post(
f"/api/v1/edit-plans/{plan.id}/ai-recommend",
json={"asset_ids": ["asset-1", "asset-2"]},
)
assert resp.status_code == 200
data = resp.json()
assert data["plan_id"] == plan.id
assert "clips" in data
assert len(data["clips"]) >= 2
assert "config" in data
assert data["total_duration"] > 0
assert "confidence" in data
def test_ai_recommend_not_found(self, ai_client):
c, repo = ai_client
resp = c.post(
"/api/v1/edit-plans/nonexistent/ai-recommend",
json={"asset_ids": ["asset-1"]},
)
assert resp.status_code == 404
def test_ai_recommend_rejects_rendering_status(self, ai_client):
c, repo = ai_client
plan = EditPlan.create("tpl-001", "渲染中计划")
plan.start_editing()
plan.start_rendering()
repo.create(plan)
resp = c.post(
f"/api/v1/edit-plans/{plan.id}/ai-recommend",
json={"asset_ids": ["asset-1"]},
)
assert resp.status_code == 400
assert "当前计划状态" in resp.json()["detail"] or "编辑计划" in resp.json()["detail"]
def test_ai_recommend_with_custom_params(self, ai_client):
c, repo = ai_client
plan = EditPlan.create("tpl-001", "自定义参数计划")
repo.create(plan)
resp = c.post(
f"/api/v1/edit-plans/{plan.id}/ai-recommend",
json={
"asset_ids": ["asset-1"],
"editing_mode": "pip",
"target_duration": 15.0,
},
)
assert resp.status_code == 200
def test_ai_recommend_invalid_duration(self, ai_client):
c, repo = ai_client
plan = EditPlan.create("tpl-001", "测试计划")
repo.create(plan)
resp = c.post(
f"/api/v1/edit-plans/{plan.id}/ai-recommend",
json={"asset_ids": ["asset-1"], "target_duration": -5.0},
)
assert resp.status_code == 422
class TestGenerateCoverEndpoint:
def test_generate_cover_ai_frame(self, ai_client):
c, repo = ai_client
plan = EditPlan.create("tpl-001", "封面测试计划")
repo.create(plan)
resp = c.post(
f"/api/v1/edit-plans/{plan.id}/generate-cover",
json={"asset_ids": ["asset-1"], "cover_type": "ai_frame"},
)
assert resp.status_code == 200
data = resp.json()
assert data["plan_id"] == plan.id
assert "cover" in data
assert data["cover"]["type"] == "ai_frame"
def test_generate_cover_manual(self, ai_client):
c, repo = ai_client
plan = EditPlan.create("tpl-001", "手动封面计划")
repo.create(plan)
resp = c.post(
f"/api/v1/edit-plans/{plan.id}/generate-cover",
json={"asset_ids": ["asset-1"], "cover_type": "manual", "frame_time": 3.5},
)
assert resp.status_code == 200
data = resp.json()
assert data["cover"]["type"] == "manual"
assert data["cover"]["frame_time"] == 3.5
def test_generate_cover_not_found(self, ai_client):
c, repo = ai_client
resp = c.post(
"/api/v1/edit-plans/nonexistent/generate-cover",
json={"asset_ids": []},
)
assert resp.status_code == 404
def test_generate_cover_default_type(self, ai_client):
c, repo = ai_client
plan = EditPlan.create("tpl-001", "默认封面计划")
repo.create(plan)
# 不传 cover_type,默认 ai_frame
resp = c.post(
f"/api/v1/edit-plans/{plan.id}/generate-cover",
json={"asset_ids": ["asset-1"]},
)
assert resp.status_code == 200
assert resp.json()["cover"]["type"] == "ai_frame"
# ---------------------------------------------------------------------------
# Config 标准化集成测试(create/update plan & template
# ---------------------------------------------------------------------------
class TestConfigNormalizationInAPI:
"""验证 create/update 端点自动标准化 config"""
def test_create_plan_normalizes_config(self, ai_client):
c, repo = ai_client
resp = c.post(
"/api/v1/edit-plans",
json={
"template_id": "tpl-001",
"name": "标准化测试",
"config": {"title": {"text": "自定义标题"}},
},
)
assert resp.status_code == 201
config = resp.json()["config"]
# 传入的 title.text 被保留
assert config["title"]["text"] == "自定义标题"
# 未传入的 title 字段填充默认值
assert config["title"]["font"] == "思源黑体"
# cover/bgm/subtitle 全部填充默认值
assert config["cover"]["type"] == "ai_frame"
assert config["bgm"]["volume"] == 0.3
assert config["subtitle"]["position"] == "bottom"
def test_update_plan_normalizes_config(self, ai_client):
c, repo = ai_client
plan = EditPlan.create("tpl-001", "更新标准化测试")
repo.create(plan)
resp = c.put(
f"/api/v1/edit-plans/{plan.id}",
json={"config": {"bgm": {"volume": 0.9}}},
)
assert resp.status_code == 200
config = resp.json()["config"]
assert config["bgm"]["volume"] == 0.9
assert config["bgm"]["source"] == "library"
assert config["cover"]["type"] == "ai_frame"
assert config["title"]["enabled"] is True