diff --git a/apps/worker/video_processing/oss_helpers.py b/apps/worker/video_processing/oss_helpers.py index 96bf9973f..05a58685a 100755 --- a/apps/worker/video_processing/oss_helpers.py +++ b/apps/worker/video_processing/oss_helpers.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) # OSS 上传配置 OSS_CONNECT_TIMEOUT = 10 # 连接超时(秒),防止 TCP 握手挂死 -OSS_UPLOAD_TOTAL_TIMEOUT = 300 # 单文件上传总超时(秒),防止网络慢时无限卡住 +OSS_UPLOAD_TOTAL_TIMEOUT = 900 # 单文件上传总超时(秒),防止网络慢时无限卡住 OSS_MULTIPART_THRESHOLD = 100 * 1024 * 1024 # 分片上传阈值:100MB 以上走分片 OSS_PART_SIZE = 8 * 1024 * 1024 # 分片大小:8MB OSS_MULTIPART_NUM_THREADS = 3 # 分片上传并发数 @@ -127,10 +127,10 @@ def download_asset(asset_storage_key: str, local_path: Path) -> bool: def _download_via_http(url: str, local_path: Path) -> bool: """通过 HTTP 下载文件(支持预签名 URL)。 - 使用流式下载避免大文件内存溢出,超时 300s。 + 使用流式下载避免大文件内存溢出,超时 900s。 """ try: - resp = requests.get(url, stream=True, timeout=300) + resp = requests.get(url, stream=True, timeout=900) resp.raise_for_status() with open(local_path, "wb") as f: for chunk in resp.iter_content(chunk_size=8 * 1024 * 1024): @@ -146,7 +146,7 @@ def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None: """上传文件到 OSS,返回公开 URL。 大文件(>100MB)自动走分片上传,降低内存峰值,减少 OOM 风险。 - 上传加总超时保护(默认 300s),防止网络异常时无限挂死。 + 上传加总超时保护(默认 900s),防止网络异常时无限挂死。 Args: local_path: 本地文件路径(Path 或 str 均可) diff --git a/apps/worker/video_processing/thumbnail_generator.py b/apps/worker/video_processing/thumbnail_generator.py index 86f4a32ac..ad6e2fd6f 100755 --- a/apps/worker/video_processing/thumbnail_generator.py +++ b/apps/worker/video_processing/thumbnail_generator.py @@ -134,3 +134,88 @@ def _format_seek_time(seconds: float) -> str: m = int((seconds % 3600) // 60) s = seconds % 60 return f"{h:02d}:{m:02d}:{s:05.2f}" + + +def generate_and_upload_thumbnail( + video_path: str, + storage_key: str, + *, + seek_ratio: float = 0.15, +) -> str: + """从视频中提取一帧缩略图并上传到 OSS。 + + Args: + video_path: 视频文件路径 + storage_key: OSS 存储 key + seek_ratio: 抽帧位置比例(默认 0.15) + + Returns: + 上传后的 URL 字符串 + + Raises: + RuntimeError: 抽帧或上传失败 + """ + from video_processing.oss_helpers import upload_to_oss + + tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) + tmp.close() + try: + frame_path = extract_first_frame(video_path, output_path=tmp.name, seek_ratio=seek_ratio) + url = upload_to_oss(frame_path, storage_key) + if not url: + raise RuntimeError(f"上传缩略图到 OSS 失败: {storage_key}") + return url + finally: + Path(tmp.name).unlink(missing_ok=True) + + +def extract_and_upload_cover_frames( + video_path: str, + plan_id: str, + *, + num_frames: int = 3, + title_text: str = "", +) -> list[dict]: + """从视频中抽取多帧作为封面候选,上传到 OSS。 + + Args: + video_path: 视频文件路径 + plan_id: 编辑计划 ID(用于生成 storage key) + num_frames: 抽取帧数(默认 3) + title_text: 标题文字(当前版本未叠加,预留参数) + + Returns: + 封面候选列表,每项包含 {"url": str, "position": float} + """ + from video_processing.ffmpeg_utils import probe_duration + from video_processing.oss_helpers import upload_to_oss + + try: + duration = probe_duration(video_path) + except Exception: + duration = 0.0 + + candidates: list[dict] = [] + # 均匀分布抽帧点:从 10% 到 90% + for i in range(num_frames): + ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1) + tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) + tmp.close() + try: + frame_path = extract_first_frame( + video_path, + output_path=tmp.name, + seek_ratio=ratio, + min_seek_seconds=0.5, + ) + storage_key = f"covers/{plan_id}/frame_{i}.jpg" + url = upload_to_oss(frame_path, storage_key) + if url: + seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0 + candidates.append({"url": url, "position": round(seek_time, 2)}) + except Exception as e: + logger.warning("[thumbnail] 封面候选帧 %d 提取失败: %s", i, e) + finally: + Path(tmp.name).unlink(missing_ok=True) + + return candidates diff --git a/apps/worker/worker_app/tasks/ingest.py b/apps/worker/worker_app/tasks/ingest.py index 8740f5a89..8ccd9f745 100755 --- a/apps/worker/worker_app/tasks/ingest.py +++ b/apps/worker/worker_app/tasks/ingest.py @@ -238,7 +238,7 @@ def ingest_asset(job_id: str) -> dict: # ── HEVC 自动转码为 1080p H.264 ────────────────────────────── # 浏览器 WebCodecs 硬件解码 HEVC 输出黑帧,上传时自动转码 # 失败时降级使用原始文件,不阻塞上传流程 - if media_type == "video" and extract_success and local_file and local_file.exists(): + if media_type == "video" and local_file and local_file.exists(): codec = (metadata.get("codec") or "").lower() if codec in ("hevc", "h265", "hvh1"): logger.info( diff --git a/packages/domain/generation_task.py b/packages/domain/generation_task.py index 631f26bdb..b1f7d0eb0 100755 --- a/packages/domain/generation_task.py +++ b/packages/domain/generation_task.py @@ -121,6 +121,7 @@ class GenerationTask: output_height: int = 720 cover_url: str = "" custom_title: str = "" + extra_meta: dict = field(default_factory=dict) logs: str = "[]" created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @@ -152,6 +153,7 @@ class GenerationTask: output_height: int = 720, cover_url: str = "", custom_title: str = "", + extra_meta: dict | None = None, ) -> "GenerationTask": if not project_id.strip() and not template_id.strip(): raise ValueError("project_id 或 template_id 至少需要提供一个") @@ -182,6 +184,7 @@ class GenerationTask: output_height=output_height, cover_url=cover_url, custom_title=custom_title, + extra_meta=dict(extra_meta) if extra_meta else {}, ) # ── 状态查询 ──────────────────────────────────────────────────────────── @@ -301,6 +304,7 @@ class GenerationTask: *, cover_url: str = "", custom_title: str = "", + extra_meta: dict | None = None, output_width: int = 0, output_height: int = 0, ) -> None: @@ -318,6 +322,8 @@ class GenerationTask: self.output_width = output_width if output_height > 0: self.output_height = output_height + if extra_meta: + self.extra_meta.update(extra_meta) self.updated_at = datetime.now(timezone.utc) # ── 日志辅助 ──────────────────────────────────────────────────────────── diff --git a/tests/integration/test_assets_api.py b/tests/integration/test_assets_api.py index a5b2d399c..a68465541 100755 --- a/tests/integration/test_assets_api.py +++ b/tests/integration/test_assets_api.py @@ -260,6 +260,30 @@ def _make_user(**overrides) -> User: return User(**defaults) +def _direct_insert_asset(client, name="test-video.mp4", storage_key=None, mime_type="video/mp4", status=None): + """Helper: insert asset directly into repo (bypass deprecated create_asset API).""" + import uuid as _uuid + + app = client.app + asset_repo = app.dependency_overrides[get_asset_repository]() + kw = {} + if status is not None: + kw["status"] = status + else: + kw["status"] = AssetStatus.READY + asset = Asset( + id=_uuid.uuid4().hex, + project_id="proj-1", + library_id="lib-1", + name=name, + storage_key=storage_key or f"uploads/{name}", + mime_type=mime_type, + **kw, + ) + asset_repo.create(asset) + return asset.id + + def _make_project(id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project: return Project(id=id, name="Test Project", owner_user_id=owner_user_id) @@ -344,8 +368,8 @@ def client(mock_storage): class TestCreateAsset: """创建素材端点测试。""" - def test_create_asset_success(self, client): - """正常创建素材成功。""" + def test_create_asset_returns_410_gone(self, client): + """create_asset 已废弃,返回 410 Gone 提示使用 ingest-jobs。""" resp = client.post( "/api/v1/assets", json={ @@ -354,59 +378,23 @@ class TestCreateAsset: "name": "new-video.mp4", "storage_key": "uploads/new-video.mp4", "mime_type": "video/mp4", - "file_size": 2048, - "duration": 15.0, }, ) - assert resp.status_code == 200 - data = resp.json() - assert data["name"] == "new-video.mp4" - assert data["project_id"] == "proj-1" - assert data["library_id"] == "lib-1" - assert data["mime_type"] == "video/mp4" - assert "id" in data - assert data["status"] == "uploading" + assert resp.status_code == 410 - def test_create_asset_project_not_found(self, client): - """项目不存在返回 404。""" + def test_create_asset_any_type_returns_410(self, client): + """所有类型都返回 410 Gone(图片/音频也废弃)。""" resp = client.post( "/api/v1/assets", json={ - "project_id": "nonexistent", + "project_id": "proj-1", "library_id": "lib-1", - "name": "test.mp4", - "storage_key": "uploads/test.mp4", - "mime_type": "video/mp4", + "name": "photo.jpg", + "storage_key": "uploads/photo.jpg", + "mime_type": "image/jpeg", }, ) - assert resp.status_code == 404 - assert "Project" in resp.json()["detail"] - - def test_create_asset_library_not_found(self, client): - """素材库不存在返回 404。""" - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "nonexistent", - "name": "test.mp4", - "storage_key": "uploads/test.mp4", - "mime_type": "video/mp4", - }, - ) - assert resp.status_code == 404 - assert "AssetLibrary" in resp.json()["detail"] - - def test_create_asset_missing_required_fields(self, client): - """缺少必填字段返回 422。""" - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "name": "test.mp4", - }, - ) - assert resp.status_code == 422 + assert resp.status_code == 410 # --------------------------------------------------------------------------- @@ -418,20 +406,26 @@ class TestListAssets: """获取素材列表端点测试。""" def _create_test_assets(self, client, count: int = 3): - """辅助方法:创建测试素材(status=ready)。""" + """辅助方法:直接插入测试素材到 repository(绕过已废弃的 create_asset API)。""" + # 通过依赖覆盖获取 asset_repo + app = client.app + asset_repo = app.dependency_overrides.get(get_asset_repository, lambda: None)() + if asset_repo is None: + return for i in range(count): - client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": f"video-{i}.mp4", - "storage_key": f"uploads/video-{i}.mp4", - "mime_type": "video/mp4", - "file_size": 1024 * (i + 1), - "status": "ready", - }, + import uuid + + asset = Asset( + id=uuid.uuid4().hex, + project_id="proj-1", + library_id="lib-1", + name=f"video-{i}.mp4", + storage_key=f"uploads/video-{i}.mp4", + mime_type="video/mp4", + file_size=1024 * (i + 1), + status=AssetStatus.READY, ) + asset_repo.create(asset) def test_empty_list(self, client): """无素材时返回空列表。""" @@ -517,17 +511,7 @@ class TestListAssets: def test_list_status_filter_uploading_visible(self, client): """uploading状态的素材默认能看到(上传后立即显示处理中)。""" - client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "uploading-test.mp4", - "storage_key": "uploads/uploading-test.mp4", - "mime_type": "video/mp4", - "status": "uploading", - }, - ) + _direct_insert_asset(client, name="uploading-test.mp4", status=AssetStatus.UPLOADING) resp = client.get("/api/v1/assets?library_id=lib-1") assert resp.status_code == 200 @@ -537,28 +521,8 @@ class TestListAssets: def test_list_with_keyword_filter(self, client): """按名称关键词过滤。""" - client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "hello-world.mp4", - "storage_key": "uploads/hello.mp4", - "mime_type": "video/mp4", - "status": "ready", - }, - ) - client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "goodbye.mp4", - "storage_key": "uploads/goodbye.mp4", - "mime_type": "video/mp4", - "status": "ready", - }, - ) + _direct_insert_asset(client, name="hello-world.mp4") + _direct_insert_asset(client, name="goodbye.mp4", mime_type="video/mp4", status=AssetStatus.READY) resp = client.get("/api/v1/assets?library_id=lib-1&keyword=hello") assert resp.status_code == 200 @@ -576,22 +540,8 @@ class TestGetAsset: """获取单个素材详情端点测试。""" def _create_asset(self, client) -> str: - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "detail-test.mp4", - "storage_key": "uploads/detail-test.mp4", - "mime_type": "video/mp4", - "file_size": 5000, - "duration": 25.0, - "width": 1280, - "height": 720, - "fps": 30.0, - }, - ) - return resp.json()["id"] + """Direct insert into repo (create_asset API is deprecated/410).""" + return _direct_insert_asset(client) def test_get_asset_success(self, client): """获取存在的素材详情成功。""" @@ -601,11 +551,7 @@ class TestGetAsset: assert resp.status_code == 200 data = resp.json() assert data["id"] == asset_id - assert data["name"] == "detail-test.mp4" - assert data["file_size"] == 5000 - assert data["duration"] == 25.0 - assert data["width"] == 1280 - assert data["height"] == 720 + assert data["name"] == "test-video.mp4" assert "file_url" in data assert "status" in data @@ -625,17 +571,8 @@ class TestUpdateAsset: """更新素材端点测试。""" def _create_asset(self, client) -> str: - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "old-name.mp4", - "storage_key": "uploads/old-name.mp4", - "mime_type": "video/mp4", - }, - ) - return resp.json()["id"] + """Direct insert into repo (create_asset API is deprecated/410).""" + return _direct_insert_asset(client) def test_update_asset_name(self, client): """更新素材名称成功。""" @@ -675,7 +612,7 @@ class TestUpdateAsset: resp = client.put(f"/api/v1/assets/{asset_id}", json={}) assert resp.status_code == 200 - assert resp.json()["name"] == "old-name.mp4" + assert resp.json()["name"] == "test-video.mp4" # --------------------------------------------------------------------------- @@ -687,17 +624,8 @@ class TestDeleteAsset: """删除素材端点测试。""" def _create_asset(self, client) -> str: - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "delete-test.mp4", - "storage_key": "uploads/delete-test.mp4", - "mime_type": "video/mp4", - }, - ) - return resp.json()["id"] + """Direct insert into repo (create_asset API is deprecated/410).""" + return _direct_insert_asset(client) def test_delete_asset_success(self, client): """删除存在的素材成功,返回 204。""" @@ -737,17 +665,8 @@ class TestBatchDeleteAssets: def _create_assets(self, client, count: int = 3) -> list[str]: ids = [] for i in range(count): - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": f"batch-{i}.mp4", - "storage_key": f"uploads/batch-{i}.mp4", - "mime_type": "video/mp4", - }, - ) - ids.append(resp.json()["id"]) + aid = _direct_insert_asset(client, name=f"batch-{i}.mp4") + ids.append(aid) return ids def test_batch_delete_success(self, client): @@ -802,17 +721,8 @@ class TestAssetTags: """素材标签相关端点测试。""" def _create_asset(self, client) -> str: - resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "tag-test.mp4", - "storage_key": "uploads/tag-test.mp4", - "mime_type": "video/mp4", - }, - ) - return resp.json()["id"] + """Direct insert into repo (create_asset API is deprecated/410).""" + return _direct_insert_asset(client) def test_add_tags_to_asset(self, client): """给素材打标签。需要先在 tag_repo 中创建标签。""" @@ -847,22 +757,8 @@ class TestAssetsCRUDFlow: def test_full_crud_flow(self, client): """测试完整的创建 → 列表 → 详情 → 更新 → 删除流程。""" - # 1. 创建 - create_resp = client.post( - "/api/v1/assets", - json={ - "project_id": "proj-1", - "library_id": "lib-1", - "name": "crud-flow.mp4", - "storage_key": "uploads/crud-flow.mp4", - "mime_type": "video/mp4", - "file_size": 8192, - "metadata": {"source": "test"}, - "status": "ready", - }, - ) - assert create_resp.status_code == 200 - asset_id = create_resp.json()["id"] + # 1. 创建 (direct insert since create_asset is 410) + asset_id = _direct_insert_asset(client, name="crud-flow.mp4") # 2. 列表中应包含 list_resp = client.get("/api/v1/assets?library_id=lib-1") diff --git a/tests/unit/test_asset_dedup.py b/tests/unit/test_asset_dedup.py index a1cbc9b72..aa10d46c9 100644 --- a/tests/unit/test_asset_dedup.py +++ b/tests/unit/test_asset_dedup.py @@ -147,6 +147,7 @@ def _build_app( storage._normalize_storage_key = lambda key: key storage.file_exists = lambda key: True storage.upload_file = MagicMock(return_value="https://oss.example.com/file.mp4") + storage.get_url = MagicMock(return_value="https://oss.example.com/file.mp4") mock_user = MagicMock(spec=AuthenticatedUser) mock_user.id = "user-1" diff --git a/tests/unit/test_create_asset_optional_project_id.py b/tests/unit/test_create_asset_optional_project_id.py deleted file mode 100644 index 22ec7614b..000000000 --- a/tests/unit/test_create_asset_optional_project_id.py +++ /dev/null @@ -1,190 +0,0 @@ -"""测试 create_asset 端点:project_id 可选,从 library 自动推导。""" - -from unittest.mock import MagicMock, patch - -import pytest -from app.api.routes.assets import create_asset -from app.auth import AuthenticatedUser -from app.schemas.asset import CreateAssetRequest -from fastapi import HTTPException - -from packages.domain import AssetStatus, ClassificationStatus - - -@pytest.fixture -def mock_user(): - user = MagicMock(spec=AuthenticatedUser) - user.user.id = "user-123" - return user - - -@pytest.fixture -def mock_library(): - lib = MagicMock() - lib.id = "lib-abc" - lib.project_id = "proj-from-library" - return lib - - -@pytest.fixture -def mock_project(): - proj = MagicMock() - proj.id = "proj-from-library" - proj.can_access.return_value = True - return proj - - -def _make_request(**overrides): - defaults = dict( - library_id="lib-abc", - name="test-audio.mp3", - storage_key="uploads/test.mp3", - mime_type="audio/mpeg", - file_size=1024, - status="uploading", - ) - defaults.update(overrides) - return CreateAssetRequest(**defaults) - - -def test_project_id_derived_from_library_when_not_provided(mock_user, mock_library, mock_project): - """前端不传 project_id 时,从 library.project_id 自动推导。""" - request = _make_request() # project_id 默认 None - - asset_repo = MagicMock() - lib_repo = MagicMock() - lib_repo.get.return_value = mock_library - proj_repo = MagicMock() - proj_repo.find_by_id.return_value = mock_project - - expected_asset = MagicMock() - expected_asset.id = "asset-1" - expected_asset.project_id = "proj-from-library" - expected_asset.library_id = "lib-abc" - expected_asset.name = "test-audio.mp3" - expected_asset.storage_key = "" - expected_asset.mime_type = "audio/mpeg" - expected_asset.metadata = {} - expected_asset.file_size = 1024 - expected_asset.thumbnail_url = None - expected_asset.duration = None - expected_asset.width = None - expected_asset.height = None - expected_asset.fps = None - expected_asset.codec = None - expected_asset.status = AssetStatus.UPLOADING - expected_asset.classification_status = ClassificationStatus.PENDING - expected_asset.quality_score = None - expected_asset.created_at = None - expected_asset.uploaded_by_user_id = "user-123" - expected_asset.tag_ids = [] - with patch("app.api.routes.assets.CreateAssetUseCase") as mock_uc: - mock_uc.return_value.execute.return_value = expected_asset - result = create_asset( - request=request, - authenticated_user=mock_user, - asset_repository=asset_repo, - asset_library_repository=lib_repo, - project_repository=proj_repo, - ) - - # 验证 project_id 被正确推导 - proj_repo.find_by_id.assert_called_once_with("proj-from-library") - # 验证 use case 使用的是推导出的 project_id - cmd = mock_uc.return_value.execute.call_args[0][0] - assert cmd.project_id == "proj-from-library" - - -def test_explicit_project_id_used_when_provided(mock_user, mock_library, mock_project): - """前端显式传 project_id 时,优先使用请求值。""" - mock_project.id = "proj-explicit" - mock_project.can_access.return_value = True - mock_library.project_id = "proj-explicit" # 匹配 - - request = _make_request(project_id="proj-explicit") - - asset_repo = MagicMock() - lib_repo = MagicMock() - lib_repo.get.return_value = mock_library - proj_repo = MagicMock() - proj_repo.find_by_id.return_value = mock_project - - mock_asset = MagicMock() - mock_asset.id = "asset-1" - mock_asset.storage_key = "" - mock_asset.mime_type = "audio/mpeg" - mock_asset.project_id = "proj-explicit" - mock_asset.library_id = "lib-abc" - mock_asset.name = "test" - mock_asset.metadata = {} - mock_asset.file_size = 0 - mock_asset.thumbnail_url = None - mock_asset.duration = None - mock_asset.width = None - mock_asset.height = None - mock_asset.fps = None - mock_asset.codec = None - mock_asset.status = AssetStatus.UPLOADING - mock_asset.classification_status = ClassificationStatus.PENDING - mock_asset.quality_score = None - mock_asset.created_at = None - mock_asset.uploaded_by_user_id = "user-123" - mock_asset.tag_ids = [] - - with patch("app.api.routes.assets.CreateAssetUseCase") as mock_uc: - mock_uc.return_value.execute.return_value = mock_asset - create_asset( - request=request, - authenticated_user=mock_user, - asset_repository=asset_repo, - asset_library_repository=lib_repo, - project_repository=proj_repo, - ) - - proj_repo.find_by_id.assert_called_once_with("proj-explicit") - cmd = mock_uc.return_value.execute.call_args[0][0] - assert cmd.project_id == "proj-explicit" - - -def test_library_not_found_returns_404(mock_user): - """素材库不存在时返回 404。""" - request = _make_request() - - lib_repo = MagicMock() - lib_repo.get.return_value = None - proj_repo = MagicMock() - asset_repo = MagicMock() - - with pytest.raises(HTTPException) as exc_info: - create_asset( - request=request, - authenticated_user=mock_user, - asset_repository=asset_repo, - asset_library_repository=lib_repo, - project_repository=proj_repo, - ) - assert exc_info.value.status_code == 404 - - -def test_library_project_mismatch_returns_400(mock_user, mock_library, mock_project): - """当 library.project_id 与请求的 project_id 不一致时返回 400。""" - mock_library.project_id = "proj-A" - mock_project.id = "proj-B" - - request = _make_request(project_id="proj-B") - - lib_repo = MagicMock() - lib_repo.get.return_value = mock_library - proj_repo = MagicMock() - proj_repo.find_by_id.return_value = mock_project - asset_repo = MagicMock() - - with pytest.raises(HTTPException) as exc_info: - create_asset( - request=request, - authenticated_user=mock_user, - asset_repository=asset_repo, - asset_library_repository=lib_repo, - project_repository=proj_repo, - ) - assert exc_info.value.status_code == 400 diff --git a/tests/unit/test_generation_task_domain.py b/tests/unit/test_generation_task_domain.py index 6c2e86ac2..0eb2f764d 100755 --- a/tests/unit/test_generation_task_domain.py +++ b/tests/unit/test_generation_task_domain.py @@ -545,3 +545,44 @@ class TestGenerationTaskTimestamps: new_task.mark_pending_from_failed() assert new_task.started_at is None assert new_task.completed_at is None + + +class TestExtraMeta: + """extra_meta 字段测试""" + + def test_create_with_extra_meta(self): + """create() 传入 extra_meta 应正确存储""" + task = GenerationTask.create( + project_id="proj-1", + asset_library_id="lib-1", + extra_meta={"source": "preview", "resolution": "1080p"}, + ) + assert task.extra_meta == {"source": "preview", "resolution": "1080p"} + + def test_create_without_extra_meta_defaults_empty(self): + """create() 不传 extra_meta 应为空 dict""" + task = GenerationTask.create(project_id="proj-1", asset_library_id="lib-1") + assert task.extra_meta == {} + + def test_mark_confirmed_with_extra_meta(self): + """mark_confirmed() 传入 extra_meta 应合并到已有字段""" + task = GenerationTask.create( + project_id="proj-1", + asset_library_id="lib-1", + extra_meta={"source": "preview"}, + ) + task.mark_confirmed(extra_meta={"confirmed_by": "user", "resolution": "1080p"}) + assert task.extra_meta["source"] == "preview" + assert task.extra_meta["confirmed_by"] == "user" + assert task.extra_meta["resolution"] == "1080p" + + def test_mark_confirmed_without_extra_meta_preserves_existing(self): + """mark_confirmed() 不传 extra_meta 不应影响已有值""" + task = GenerationTask.create( + project_id="proj-1", + asset_library_id="lib-1", + extra_meta={"key": "value"}, + ) + task.mark_confirmed(cover_url="https://example.com/cover.jpg") + assert task.extra_meta == {"key": "value"} + assert task.cover_url == "https://example.com/cover.jpg"