""" 素材重复上传检测 单元测试 覆盖: - 表单上传(multipart)命中去重 → 直接返回已有 asset_id,不上传 OSS - 直传 OSS complete 命中去重 → 直接返回已有 asset_id,不创建 ingest job - 未命中去重 → 正常创建 ingest job - file_hash 为空 → 跳过去重检测 - IngestJob 透传 file_hash 到 Asset """ from __future__ import annotations import os import sys from pathlib import Path 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") sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) from fastapi import FastAPI from fastapi.testclient import TestClient from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, IngestJob, Project # --------------------------------------------------------------------------- # Stub repositories # --------------------------------------------------------------------------- class StubProjectRepository: def __init__(self, projects: dict | None = None): self._projects = projects or {} def get(self, project_id: str): return self._projects.get(project_id) def find_by_id(self, project_id: str): return self._projects.get(project_id) class StubAssetLibraryRepository: def __init__(self, libraries: dict | None = None): self._libraries = libraries or {} def find_by_project(self, project_id: str, kind=None) -> list: 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: """支持 find_by_library_and_file_hash 去重检测。""" def __init__(self, assets: list[Asset] | None = None): self._assets = assets or [] def find_by_library_and_file_hash(self, library_id: str, file_hash: str) -> Asset | None: for a in self._assets: if a.library_id == library_id and a.file_hash == file_hash: return a return None def create(self, asset: Asset) -> Asset: self._assets.append(asset) return asset class StubIngestJobRepository: def __init__(self): self._jobs: dict[str, IngestJob] = {} def create(self, job: IngestJob) -> IngestJob: self._jobs[job.id] = job return job def get(self, job_id: str) -> IngestJob | None: return self._jobs.get(job_id) def update(self, job: IngestJob) -> IngestJob: self._jobs[job.id] = job return job # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- DUPE_HASH = "a" * 32 def _make_project(id: str = "proj-1", owner_user_id: str = "user-1") -> 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 Library", project_id=project_id, kind=AssetLibraryKind.VIDEO) def _make_existing_asset( id: str = "existing-asset-1", library_id: str = "lib-1", file_hash: str = DUPE_HASH, ) -> Asset: return Asset( id=id, project_id="proj-1", library_id=library_id, name="existing.mp4", storage_key="uploads/existing/existing.mp4", mime_type="video/mp4", file_hash=file_hash, status=AssetStatus.READY, ) def _build_app( project_repo=None, library_repo=None, asset_repo=None, ingest_repo=None, storage=None, ): from app.api.routes.upload 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_ingest_job_repository, get_project_repository, ) app = FastAPI() app.include_router(router, prefix="/api/v1") project_repo = project_repo or StubProjectRepository() library_repo = library_repo or StubAssetLibraryRepository() asset_repo = asset_repo or StubAssetRepository() ingest_repo = ingest_repo or StubIngestJobRepository() storage = storage or MagicMock() storage.is_configured = True 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") mock_user = MagicMock(spec=AuthenticatedUser) mock_user.id = "user-1" mock_user.email = "test@example.com" app.dependency_overrides[get_current_user] = lambda: mock_user app.dependency_overrides[get_project_repository] = lambda: project_repo app.dependency_overrides[get_asset_library_repository] = lambda: library_repo app.dependency_overrides[get_asset_repository] = lambda: asset_repo app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo app.dependency_overrides[get_storage_service] = lambda: storage return app def _client(**kwargs) -> TestClient: return TestClient(_build_app(**kwargs)) # --------------------------------------------------------------------------- # 测试用例 # --------------------------------------------------------------------------- class TestMultipartUploadDedup: """表单上传(POST /api/v1/assets)去重检测。""" def test_dedup_hit_returns_existing_asset(self): """file_hash 命中已有素材 → 返回 duplicated=true + asset_id,不上传 OSS。""" project = _make_project() library = _make_library() existing = _make_existing_asset() client = _client( project_repo=StubProjectRepository({project.id: project}), library_repo=StubAssetLibraryRepository({library.id: library}), asset_repo=StubAssetRepository([existing]), ) resp = client.post( "/api/v1", data={ "project_id": project.id, "library_id": library.id, "file_hash": DUPE_HASH, }, files={"file": ("test.mp4", b"fake-video-data", "video/mp4")}, ) assert resp.status_code == 200 body = resp.json() assert body["duplicated"] is True assert body["asset_id"] == existing.id assert body["ingest_job_id"] == "" def test_dedup_miss_creates_ingest_job(self): """file_hash 未命中 → 正常上传并创建 ingest job。""" project = _make_project() library = _make_library() client = _client( project_repo=StubProjectRepository({project.id: project}), library_repo=StubAssetLibraryRepository({library.id: library}), asset_repo=StubAssetRepository([]), # 无已有素材 ) resp = client.post( "/api/v1", data={ "project_id": project.id, "library_id": library.id, "file_hash": "b" * 32, # 新的 hash }, files={"file": ("test.mp4", b"fake-video-data", "video/mp4")}, ) assert resp.status_code == 200 body = resp.json() assert body["duplicated"] is False assert body["ingest_job_id"] != "" def test_empty_hash_skips_dedup(self): """file_hash 为空 → 跳过去重检测,直接上传。""" project = _make_project() library = _make_library() existing = _make_existing_asset() client = _client( project_repo=StubProjectRepository({project.id: project}), library_repo=StubAssetLibraryRepository({library.id: library}), asset_repo=StubAssetRepository([existing]), ) resp = client.post( "/api/v1", data={ "project_id": project.id, "library_id": library.id, # 不传 file_hash }, files={"file": ("test.mp4", b"fake-video-data", "video/mp4")}, ) assert resp.status_code == 200 body = resp.json() assert body["duplicated"] is False class TestDirectUploadDedup: """直传 OSS complete(POST /api/v1/direct/complete)去重检测。""" def test_dedup_hit_returns_existing_asset(self): """complete 阶段 file_hash 命中 → 返回 duplicated=true。""" project = _make_project() library = _make_library() existing = _make_existing_asset() client = _client( project_repo=StubProjectRepository({project.id: project}), library_repo=StubAssetLibraryRepository({library.id: library}), asset_repo=StubAssetRepository([existing]), ) resp = client.post( "/api/v1/direct/complete", json={ "project_id": project.id, "library_id": library.id, "storage_key": "uploads/abc/test.mp4", "file_hash": DUPE_HASH, }, ) assert resp.status_code == 200 body = resp.json() assert body["duplicated"] is True assert body["asset_id"] == existing.id assert body["ingest_job_id"] == "" def test_dedup_miss_creates_ingest_job(self): """complete 阶段 file_hash 未命中 → 创建 ingest job。""" project = _make_project() library = _make_library() client = _client( project_repo=StubProjectRepository({project.id: project}), library_repo=StubAssetLibraryRepository({library.id: library}), asset_repo=StubAssetRepository([]), ) resp = client.post( "/api/v1/direct/complete", json={ "project_id": project.id, "library_id": library.id, "storage_key": "uploads/abc/test.mp4", "file_hash": "c" * 32, }, ) assert resp.status_code == 200 body = resp.json() assert body["duplicated"] is False assert body["ingest_job_id"] != "" class TestIngestJobFileHashPassthrough: """file_hash 从上传接口透传到 IngestJob。""" def test_ingest_job_stores_file_hash(self): """上传时传入的 file_hash 应保存到 IngestJob 实体。""" project = _make_project() library = _make_library() ingest_repo = StubIngestJobRepository() client = _client( project_repo=StubProjectRepository({project.id: project}), library_repo=StubAssetLibraryRepository({library.id: library}), asset_repo=StubAssetRepository([]), ingest_repo=ingest_repo, ) new_hash = "d" * 32 client.post( "/api/v1", data={ "project_id": project.id, "library_id": library.id, "file_hash": new_hash, }, files={"file": ("test.mp4", b"fake-video-data", "video/mp4")}, ) # 验证 IngestJob 存储了 file_hash assert len(ingest_repo._jobs) == 1 job = list(ingest_repo._jobs.values())[0] assert job.file_hash == new_hash