""" 素材 CRUD API 集成测试 覆盖端点: - POST /assets — 创建素材 - GET /assets — 获取素材列表 - GET /assets/{id} — 获取单个素材详情 - PUT /assets/{id} — 更新素材 - DELETE /assets/{id} — 删除素材 - POST /assets/batch-delete — 批量删除素材 - POST /assets/{id}/tags — 素材打标签 - DELETE /assets/{id}/tags/{tag_id} — 移除标签 使用 FastAPI TestClient + dependency_overrides 模式, 导入真实模块,mock 外部依赖。 """ from __future__ import annotations import os import sys from pathlib import Path from unittest.mock import MagicMock # ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ────────────────────────── os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") import pytest from fastapi import FastAPI from fastapi.testclient import TestClient sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) from app.api.routes.assets import router from app.auth import AuthenticatedUser, get_current_user from app.core.storage import get_storage_service from app.dependencies import ( get_asset_library_repository, get_asset_repository, get_project_repository, get_tag_repository, ) from packages.domain import ( Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus, Project, Tag, User, ) # --------------------------------------------------------------------------- # 1. Stub Repository 实现 # --------------------------------------------------------------------------- class StubProjectRepository: def __init__(self, projects: dict[str, Project] | None = None): self._projects = projects or {} def find_by_id(self, project_id: str) -> Project | None: return self._projects.get(project_id) def find_accessible_projects(self, user_id: str) -> list[Project]: return [p for p in self._projects.values() if p.can_access(user_id)] def count_by_owner(self, owner_user_id: str) -> int: return len([p for p in self._projects.values() if p.owner_user_id == owner_user_id]) class StubAssetLibraryRepository: def __init__(self, libraries: dict[str, AssetLibrary] | None = None): self._libraries = libraries or {} def get(self, library_id: str) -> AssetLibrary | None: return self._libraries.get(library_id) def find_by_id(self, library_id: str) -> AssetLibrary | None: return self._libraries.get(library_id) def find_by_project(self, project_id: str, kind=None) -> list[AssetLibrary]: items = [lib for lib in self._libraries.values() if lib.project_id == project_id] if kind is not None: items = [lib for lib in items if lib.kind == kind] return items class StubAssetRepository: def __init__(self, assets: dict[str, Asset] | None = None): self._assets = assets or {} def create(self, asset: Asset) -> Asset: self._assets[asset.id] = asset return asset def get(self, asset_id: str) -> Asset | None: return self._assets.get(asset_id) def find_by_id(self, asset_id: str) -> Asset | None: return self._assets.get(asset_id) def find_by_library(self, library_id: str, skip: int = 0, limit: int = 100) -> list[Asset]: items = [a for a in self._assets.values() if a.library_id == library_id] return items[skip : skip + limit] def find_by_library_and_file_type(self, library_id: str, file_type: str) -> list[Asset]: return [ a for a in self._assets.values() if a.library_id == library_id and a.mime_type and a.mime_type.startswith(file_type) ] def find_by_project(self, project_id: str, skip: int = 0, limit: int = 100) -> list[Asset]: items = [a for a in self._assets.values() if a.project_id == project_id] return items[skip : skip + limit] def update(self, asset: Asset) -> Asset: self._assets[asset.id] = asset return asset def delete(self, asset_id: str) -> bool: if asset_id in self._assets: del self._assets[asset_id] return True return False def batch_delete(self, asset_ids: list[str]) -> int: """软删除:标记 status=deleted。""" from datetime import datetime, timezone from packages.domain import AssetStatus count = 0 for aid in asset_ids: asset = self._assets.get(aid) if asset and asset.status != AssetStatus.DELETED: asset.status = AssetStatus.DELETED asset.updated_at = datetime.now(timezone.utc) count += 1 return count def batch_update_metadata(self, asset_ids: list[str], metadata_patch: dict) -> int: from datetime import datetime, timezone count = 0 for aid in asset_ids: asset = self._assets.get(aid) if asset: asset.metadata = {**asset.metadata, **metadata_patch} asset.updated_at = datetime.now(timezone.utc) count += 1 return count def batch_add_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int: from datetime import datetime, timezone count = 0 for aid in asset_ids: asset = self._assets.get(aid) if asset: changed = False for tid in tag_ids: if tid not in asset.tag_ids: asset.tag_ids.append(tid) changed = True if changed: asset.updated_at = datetime.now(timezone.utc) count += 1 return count def batch_replace_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int: from datetime import datetime, timezone count = 0 for aid in asset_ids: asset = self._assets.get(aid) if asset: asset.tag_ids = list(tag_ids) asset.updated_at = datetime.now(timezone.utc) count += 1 return count def count_by_project(self, project_id: str) -> int: return len([a for a in self._assets.values() if a.project_id == project_id]) def count_by_project_ids(self, project_ids: list[str]) -> int: return len([a for a in self._assets.values() if a.project_id in project_ids]) def find_by_library_and_file_hash(self, library_id: str, file_hash: str) -> Asset | None: if not file_hash: return None for asset in self._assets.values(): if asset.library_id == library_id and getattr(asset, "file_hash", "") == file_hash: return asset return None class StubTagRepository: def __init__(self, tags: dict[str, Tag] | None = None): self._tags = tags or {} def get(self, tag_id: str) -> Tag | None: return self._tags.get(tag_id) def create(self, tag: Tag) -> Tag: self._tags[tag.id] = tag return tag def list_by_user(self, user_id: str, skip: int = 0, limit: int = 100) -> list[Tag]: return [t for t in self._tags.values() if t.user_id == user_id][skip : skip + limit] # --------------------------------------------------------------------------- # 2. Helpers & Fixtures # --------------------------------------------------------------------------- def _make_user(**overrides) -> User: from datetime import datetime, timezone defaults = dict( id="user-test-001", email="test@example.com", display_name="Test User", username="testuser", subscription_plan="free", subscription_status="active", max_projects=3, max_storage_gb=10, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), ) defaults.update(overrides) return User(**defaults) 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) def _make_library(id: str = "lib-1", project_id: str = "proj-1") -> AssetLibrary: return AssetLibrary( id=id, name="Test Video Library", project_id=project_id, kind=AssetLibraryKind.VIDEO, ) def _make_asset(**overrides) -> Asset: defaults = dict( id="asset-1", project_id="proj-1", library_id="lib-1", name="test-video.mp4", storage_key="uploads/test-video.mp4", mime_type="video/mp4", file_size=1024 * 1024, status=AssetStatus.READY, classification_status=ClassificationStatus.COMPLETED, uploaded_by_user_id="user-test-001", duration=30.5, width=1920, height=1080, fps=25.0, quality_score=85.0, ) defaults.update(overrides) return Asset(**defaults) def _make_tag(id: str = "tag-1", user_id: str = "user-test-001", name: str = "精彩片段") -> Tag: return Tag(id=id, user_id=user_id, name=name) @pytest.fixture def mock_storage(): storage = MagicMock() storage.get_download_url.return_value = "https://oss.example.com/uploads/test.mp4?sign=xxx" return storage @pytest.fixture def client(mock_storage): """创建带有依赖覆盖的 TestClient。""" test_app = FastAPI() test_app.include_router(router, prefix="/api/v1/assets") project = _make_project() library = _make_library() asset_repo = StubAssetRepository() project_repo = StubProjectRepository({project.id: project}) library_repo = StubAssetLibraryRepository({library.id: library}) tag_repo = StubTagRepository() def _override_current_user(): mock_auth = MagicMock(spec=AuthenticatedUser) mock_auth.user = _make_user() return mock_auth test_app.dependency_overrides[get_current_user] = _override_current_user test_app.dependency_overrides[get_project_repository] = lambda: project_repo test_app.dependency_overrides[get_asset_library_repository] = lambda: library_repo test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo test_app.dependency_overrides[get_tag_repository] = lambda: tag_repo test_app.dependency_overrides[get_storage_service] = lambda: mock_storage yield TestClient(test_app) test_app.dependency_overrides.clear() # --------------------------------------------------------------------------- # 3. POST /assets — 创建素材 # --------------------------------------------------------------------------- class TestCreateAsset: """创建素材端点测试。""" def test_create_asset_success(self, client): """正常创建素材成功。""" resp = client.post( "/api/v1/assets", json={ "project_id": "proj-1", "library_id": "lib-1", "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" def test_create_asset_project_not_found(self, client): """项目不存在返回 404。""" resp = client.post( "/api/v1/assets", json={ "project_id": "nonexistent", "library_id": "lib-1", "name": "test.mp4", "storage_key": "uploads/test.mp4", "mime_type": "video/mp4", }, ) 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 # --------------------------------------------------------------------------- # 4. GET /assets — 获取素材列表 # --------------------------------------------------------------------------- class TestListAssets: """获取素材列表端点测试。""" def _create_test_assets(self, client, count: int = 3): """辅助方法:创建测试素材。""" 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), }, ) def test_empty_list(self, client): """无素材时返回空列表。""" resp = client.get("/api/v1/assets?library_id=lib-1") assert resp.status_code == 200 data = resp.json() assert data["items"] == [] assert data["total"] == 0 def test_list_assets_by_library(self, client): """按素材库列出素材。""" self._create_test_assets(client, 3) resp = client.get("/api/v1/assets?library_id=lib-1") assert resp.status_code == 200 data = resp.json() assert len(data["items"]) == 3 assert data["total"] >= 3 def test_list_assets_by_project(self, client): """按项目列出素材。""" self._create_test_assets(client, 2) resp = client.get("/api/v1/assets?project_id=proj-1") assert resp.status_code == 200 data = resp.json() assert len(data["items"]) == 2 def test_list_pagination(self, client): """分页参数生效。""" self._create_test_assets(client, 5) resp = client.get("/api/v1/assets?library_id=lib-1&skip=0&limit=2") assert resp.status_code == 200 data = resp.json() assert len(data["items"]) == 2 assert data["skip"] == 0 assert data["limit"] == 2 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", }, ) 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", }, ) resp = client.get("/api/v1/assets?library_id=lib-1&keyword=hello") assert resp.status_code == 200 data = resp.json() assert len(data["items"]) == 1 assert "hello" in data["items"][0]["name"].lower() # --------------------------------------------------------------------------- # 5. GET /assets/{asset_id} — 获取单个素材详情 # --------------------------------------------------------------------------- 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"] def test_get_asset_success(self, client): """获取存在的素材详情成功。""" asset_id = self._create_asset(client) resp = client.get(f"/api/v1/assets/{asset_id}") 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 "file_url" in data assert "status" in data def test_get_nonexistent_asset_returns_404(self, client): """获取不存在的素材返回 404。""" resp = client.get("/api/v1/assets/nonexistent-asset-id") assert resp.status_code == 404 assert "not found" in resp.json()["detail"].lower() or "Asset" in resp.json()["detail"] # --------------------------------------------------------------------------- # 6. PUT /assets/{asset_id} — 更新素材 # --------------------------------------------------------------------------- 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"] def test_update_asset_name(self, client): """更新素材名称成功。""" asset_id = self._create_asset(client) resp = client.put( f"/api/v1/assets/{asset_id}", json={"name": "new-name.mp4"}, ) assert resp.status_code == 200 assert resp.json()["name"] == "new-name.mp4" def test_update_asset_metadata(self, client): """更新素材 metadata 成功。""" asset_id = self._create_asset(client) resp = client.put( f"/api/v1/assets/{asset_id}", json={"metadata": {"description": "这是一段测试视频", "category": "demo"}}, ) assert resp.status_code == 200 data = resp.json() assert data["metadata"]["description"] == "这是一段测试视频" assert data["metadata"]["category"] == "demo" def test_update_nonexistent_asset_returns_404(self, client): """更新不存在的素材返回 404。""" resp = client.put( "/api/v1/assets/nonexistent-id", json={"name": "test.mp4"}, ) assert resp.status_code == 404 def test_update_with_empty_body(self, client): """空请求体也应返回成功(不修改任何字段)。""" asset_id = self._create_asset(client) resp = client.put(f"/api/v1/assets/{asset_id}", json={}) assert resp.status_code == 200 assert resp.json()["name"] == "old-name.mp4" # --------------------------------------------------------------------------- # 7. DELETE /assets/{asset_id} — 删除素材 # --------------------------------------------------------------------------- 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"] def test_delete_asset_success(self, client): """删除存在的素材成功,返回 204。""" asset_id = self._create_asset(client) resp = client.delete(f"/api/v1/assets/{asset_id}") assert resp.status_code == 204 # 验证已删除 get_resp = client.get(f"/api/v1/assets/{asset_id}") assert get_resp.status_code == 404 def test_delete_nonexistent_asset_returns_404(self, client): """删除不存在的素材返回 404。""" resp = client.delete("/api/v1/assets/nonexistent-id") assert resp.status_code == 404 def test_delete_idempotent(self, client): """删除后再次删除返回 404(幂等性)。""" asset_id = self._create_asset(client) resp1 = client.delete(f"/api/v1/assets/{asset_id}") assert resp1.status_code == 204 resp2 = client.delete(f"/api/v1/assets/{asset_id}") assert resp2.status_code == 404 # --------------------------------------------------------------------------- # 8. POST /assets/batch-delete — 批量删除素材 # --------------------------------------------------------------------------- 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"]) return ids def test_batch_delete_success(self, client): """批量删除成功(软删除)。""" ids = self._create_assets(client, 3) resp = client.post( "/api/v1/assets/batch-delete", json={"asset_ids": ids[:2]}, ) assert resp.status_code == 200 data = resp.json() assert data["success_count"] == 2 assert len(data["failed_ids"]) == 0 # 软删除:记录仍在,status 变为 deleted for aid in ids[:2]: r = client.get(f"/api/v1/assets/{aid}") assert r.status_code == 200 assert r.json()["status"] == "deleted" def test_batch_delete_with_nonexistent_ids(self, client): """批量删除包含不存在的 ID,失败的计入 failed_ids。""" ids = self._create_assets(client, 2) ids.append("nonexistent-id") resp = client.post( "/api/v1/assets/batch-delete", json={"asset_ids": ids}, ) assert resp.status_code == 200 data = resp.json() assert data["success_count"] == 2 assert "nonexistent-id" in data["failed_ids"] assert "nonexistent-id" in data["failed_details"] assert data["failed_details"]["nonexistent-id"] == "not_found" def test_batch_delete_empty_list_returns_422(self, client): """空列表返回 422。""" resp = client.post( "/api/v1/assets/batch-delete", json={"asset_ids": []}, ) assert resp.status_code == 422 # --------------------------------------------------------------------------- # 9. 标签相关测试 # --------------------------------------------------------------------------- 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"] def test_add_tags_to_asset(self, client): """给素材打标签。需要先在 tag_repo 中创建标签。""" # 由于 tag_repo 在 fixture 内部创建,我们通过另一种方式测试 # 直接测试不存在的标签返回 404 asset_id = self._create_asset(client) resp = client.post( f"/api/v1/assets/{asset_id}/tags", json={"tag_ids": ["nonexistent-tag"]}, ) # 标签不存在应返回 404 assert resp.status_code == 404 assert "Tag" in resp.json()["detail"] def test_remove_tag_from_asset(self, client): """移除素材标签(幂等,不存在也返回 204)。""" asset_id = self._create_asset(client) resp = client.delete(f"/api/v1/assets/{asset_id}/tags/nonexistent-tag") # 移除标签是幂等的,标签不存在也应返回 204 assert resp.status_code == 204 # --------------------------------------------------------------------------- # 10. 跨端点集成场景 # --------------------------------------------------------------------------- class TestAssetsCRUDFlow: """素材完整 CRUD 流程测试。""" 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"}, }, ) assert create_resp.status_code == 200 asset_id = create_resp.json()["id"] # 2. 列表中应包含 list_resp = client.get("/api/v1/assets?library_id=lib-1") assert list_resp.status_code == 200 assert any(item["id"] == asset_id for item in list_resp.json()["items"]) # 3. 获取详情 detail_resp = client.get(f"/api/v1/assets/{asset_id}") assert detail_resp.status_code == 200 assert detail_resp.json()["name"] == "crud-flow.mp4" # 4. 更新名称 update_resp = client.put( f"/api/v1/assets/{asset_id}", json={"name": "crud-flow-updated.mp4"}, ) assert update_resp.status_code == 200 assert update_resp.json()["name"] == "crud-flow-updated.mp4" # 5. 验证更新生效 detail_resp2 = client.get(f"/api/v1/assets/{asset_id}") assert detail_resp2.json()["name"] == "crud-flow-updated.mp4" # 6. 删除 delete_resp = client.delete(f"/api/v1/assets/{asset_id}") assert delete_resp.status_code == 204 # 7. 验证已删除 detail_resp3 = client.get(f"/api/v1/assets/{asset_id}") assert detail_resp3.status_code == 404 if __name__ == "__main__": pytest.main([__file__, "-v"])