""" 导出设置 API 单元测试 覆盖: - GET /export-presets - 导出预设列表 - GET /{plan_id}/export - 获取导出配置 - PUT /{plan_id}/export - 更新导出配置 """ from __future__ import annotations import os import sys from datetime import datetime from pathlib import Path from typing import Optional 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")) from fastapi import FastAPI from fastapi.testclient import TestClient from packages.domain.config_schemas import normalize_plan_config from packages.domain.edit_plan import EditPlan, EditPlanStatus # --------------------------------------------------------------------------- # Stub Repository # --------------------------------------------------------------------------- class StubEditPlanRepository: def __init__(self, plans: dict[str, EditPlan] | None = None): self._plans = plans or {} def get(self, plan_id: str) -> Optional[EditPlan]: return self._plans.get(plan_id) def create(self, plan: EditPlan) -> EditPlan: self._plans[plan.id] = plan return plan def update(self, plan: EditPlan) -> EditPlan: self._plans[plan.id] = plan return plan def list_all(self, *, status=None, skip=0, limit=50): return list(self._plans.values())[skip : skip + limit] def list_by_template(self, template_id, *, status=None, skip=0, limit=50): return [p for p in self._plans.values() if p.template_id == template_id][skip : skip + limit] def delete(self, plan_id: str) -> bool: return self._plans.pop(plan_id, None) is not None def count(self, *, status=None, template_id=None): return len(self._plans) class StubEditPlanClipRepository: def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100): return [] def count(self, plan_id, *, status=None): return 0 def get(self, clip_id: str): return None def create(self, clip): return clip def update(self, clip): return clip def delete(self, clip_id: str) -> bool: return False def delete_by_plan(self, plan_id: str) -> int: return 0 # --------------------------------------------------------------------------- # Test Fixtures # --------------------------------------------------------------------------- def _make_sample_plan(plan_id="plan-001"): return EditPlan( id=plan_id, template_id="tpl-001", name="测试计划", status=EditPlanStatus.EDITING, total_duration=30.0, config=normalize_plan_config({}), project_id="", created_by_user_id="user-001", created_at=datetime(2026, 7, 16, 10, 0, 0), updated_at=datetime(2026, 7, 16, 10, 0, 0), ) def _create_test_app(): import app.services.edit_plan_service as service_module from app.api.routes.edit_plans import router plan = _make_sample_plan() stub_plan_repo = StubEditPlanRepository({plan.id: plan}) stub_clip_repo = StubEditPlanClipRepository() original_plan_repo = service_module.SQLAlchemyEditPlanRepository original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock() app = FastAPI() app.include_router(router, prefix="/api/v1/edit-plans") def _mock_auth(): mock = MagicMock() mock.user.id = "user-001" return mock import app.api.routes._helpers as helpers_module original_check = helpers_module.check_project_access helpers_module.check_project_access = lambda *a, **kw: None from app.api.routes import edit_plans as main_module app.dependency_overrides[main_module.get_current_user] = _mock_auth app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock() app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock() import app.api.routes.edit_plans_export as export_module app.dependency_overrides[export_module.get_current_user] = _mock_auth app.dependency_overrides[export_module.get_db_session] = lambda: MagicMock() app.dependency_overrides[export_module.get_project_repository] = lambda: MagicMock() def cleanup(): service_module.SQLAlchemyEditPlanRepository = original_plan_repo service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo helpers_module.check_project_access = original_check return app, stub_plan_repo, cleanup @pytest.fixture def export_client(): app, plan_repo, cleanup = _create_test_app() yield TestClient(app), plan_repo cleanup() # --------------------------------------------------------------------------- # Export Presets 测试 # --------------------------------------------------------------------------- class TestExportPresets: def test_list_all_presets(self, export_client): c, _ = export_client resp = c.get("/api/v1/edit-plans/export-presets") assert resp.status_code == 200 data = resp.json() assert data["total"] >= 5 assert len(data["items"]) == data["total"] first = data["items"][0] assert "id" in first assert "name" in first assert "resolution" in first assert "fps" in first assert "video_bitrate" in first assert "format" in first assert "description" in first assert "size_hint" in first def test_preset_has_valid_resolution(self, export_client): c, _ = export_client resp = c.get("/api/v1/edit-plans/export-presets") data = resp.json() for item in data["items"]: assert "x" in item["resolution"] assert item["fps"] >= 15 assert item["fps"] <= 60 assert item["format"] in ("mp4", "mov") # --------------------------------------------------------------------------- # GET /{plan_id}/export 测试 # --------------------------------------------------------------------------- class TestGetExportConfig: def test_default_export_config(self, export_client): c, _ = export_client resp = c.get("/api/v1/edit-plans/plan-001/export") assert resp.status_code == 200 data = resp.json() assert data["resolution"] == "1080x1920" assert data["fps"] == 30 assert data["video_bitrate"] == 8000 assert data["audio_bitrate"] == 128 assert data["format"] == "mp4" assert data["quality_preset"] == "balanced" assert data["watermark_enabled"] is False assert data["watermark_text"] == "" def test_export_not_found(self, export_client): c, _ = export_client resp = c.get("/api/v1/edit-plans/plan-nonexist/export") assert resp.status_code == 404 # --------------------------------------------------------------------------- # PUT /{plan_id}/export 测试 # --------------------------------------------------------------------------- class TestUpdateExportConfig: def test_update_resolution_and_fps(self, export_client): c, plan_repo = export_client resp = c.put( "/api/v1/edit-plans/plan-001/export", json={"resolution": "720x1280", "fps": 60}, ) assert resp.status_code == 200 data = resp.json() assert data["resolution"] == "720x1280" assert data["fps"] == 60 plan = plan_repo.get("plan-001") assert plan.config["export"]["resolution"] == "720x1280" assert plan.config["export"]["fps"] == 60 def test_update_bitrate(self, export_client): c, plan_repo = export_client resp = c.put( "/api/v1/edit-plans/plan-001/export", json={"video_bitrate": 12000, "audio_bitrate": 192}, ) assert resp.status_code == 200 data = resp.json() assert data["video_bitrate"] == 12000 assert data["audio_bitrate"] == 192 def test_update_format(self, export_client): c, _ = export_client resp = c.put( "/api/v1/edit-plans/plan-001/export", json={"format": "mov"}, ) assert resp.status_code == 200 assert resp.json()["format"] == "mov" def test_invalid_format(self, export_client): c, _ = export_client resp = c.put( "/api/v1/edit-plans/plan-001/export", json={"format": "avi"}, ) assert resp.status_code == 422 def test_update_quality_preset(self, export_client): c, _ = export_client resp = c.put( "/api/v1/edit-plans/plan-001/export", json={"quality_preset": "best"}, ) assert resp.status_code == 200 assert resp.json()["quality_preset"] == "best" def test_invalid_quality_preset(self, export_client): c, _ = export_client resp = c.put( "/api/v1/edit-plans/plan-001/export", json={"quality_preset": "ultimate"}, ) assert resp.status_code == 422 def test_update_watermark(self, export_client): c, plan_repo = export_client resp = c.put( "/api/v1/edit-plans/plan-001/export", json={"watermark_enabled": True, "watermark_text": "我的视频"}, ) assert resp.status_code == 200 data = resp.json() assert data["watermark_enabled"] is True assert data["watermark_text"] == "我的视频" plan = plan_repo.get("plan-001") assert plan.config["export"]["watermark_enabled"] is True assert plan.config["export"]["watermark_text"] == "我的视频" def test_invalid_resolution_format(self, export_client): c, _ = export_client resp = c.put( "/api/v1/edit-plans/plan-001/export", json={"resolution": "1080*1920"}, ) assert resp.status_code == 422 def test_resolution_too_large(self, export_client): c, _ = export_client resp = c.put( "/api/v1/edit-plans/plan-001/export", json={"resolution": "8000x8000"}, ) assert resp.status_code == 422 def test_fps_out_of_range(self, export_client): c, _ = export_client resp = c.put( "/api/v1/edit-plans/plan-001/export", json={"fps": 120}, ) assert resp.status_code == 422 def test_export_not_found(self, export_client): c, _ = export_client resp = c.put( "/api/v1/edit-plans/plan-nonexist/export", json={"fps": 30}, ) assert resp.status_code == 404 def test_partial_update_preserves_other_fields(self, export_client): c, _ = export_client # 先修改一个 c.put("/api/v1/edit-plans/plan-001/export", json={"resolution": "720x1280"}) # 再修改另一个 resp = c.put("/api/v1/edit-plans/plan-001/export", json={"fps": 60}) data = resp.json() # 分辨率应该保持 assert data["resolution"] == "720x1280" # fps 更新了 assert data["fps"] == 60 # 其他默认值不变 assert data["format"] == "mp4" assert data["video_bitrate"] == 8000