"""#1714 prepare_direct_upload 去重 + 预建 asset 测试。 覆盖 4 类用例: - 第一次上传:prepare 返回 duplicated=false + asset_id 非空 - 第二次同 hash:prepare 返回 duplicated=true, skip_transfer=true - 同 client_upload_id 重试:prepare 也直接跳过 - file_hash 空:走老逻辑,duplicated=false,无 asset_id 以及: - pre-create 的 PROCESSING 占位不被"文件名兜底去重"误命中 - _create_pending_asset find-or-create 复用现有记录 """ from __future__ import annotations import os import sys from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock import pytest os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret") os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from apps.api.app.api.routes import upload as upload_route # noqa: E402 from packages.domain.entities import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, Project # noqa: E402 # --------------------------------------------------------------------------- # Fake repository # --------------------------------------------------------------------------- class _FakeAssetRepo: """内存 asset 仓储:实现 prepare/complete 去重需要的所有方法。""" def __init__(self): self.assets = {} # id -> Asset self.saved = 0 self.updated = 0 def create(self, asset): self.assets[asset.id] = asset self.saved += 1 return asset def update(self, asset): self.assets[asset.id] = asset self.updated += 1 return asset def find_by_id(self, asset_id): return self.assets.get(asset_id) def find_by_library_and_file_hash(self, library_id, file_hash): if not file_hash: return None for a in self.assets.values(): if a.library_id == library_id and a.file_hash == file_hash: return a return None def find_by_library_and_client_upload_id(self, library_id, client_upload_id): if not client_upload_id: return None for a in self.assets.values(): if a.library_id == library_id and a.client_upload_id == client_upload_id: return a return None def find_recent_active_by_library_and_name(self, library_id, name, within_minutes=30, file_size=0): return None def _make_asset(**kw): defaults = dict( project_id="p-1", library_id="lib-1", name="existing.mp4", storage_key="uploads/old/existing.mp4", mime_type="video/mp4", status=AssetStatus.READY, file_hash="existinghash", ) defaults.update(kw) return Asset(id=defaults.pop("id", "existing-asset"), **defaults) def _make_pending(**kw): defaults = dict( project_id="p-1", library_id="lib-1", name="test.mp4", storage_key="uploads/abc/test.mp4", mime_type="video/mp4", status=AssetStatus.PROCESSING, file_hash="abc123", ) defaults.update(kw) return Asset(id=defaults.pop("id", "pending-asset"), **defaults) def _user(): return SimpleNamespace(user=SimpleNamespace(id="user-1"), session_id="s", token_type="t") class _StubProjectRepo: def __init__(self, project): self._p = project def get(self, pid): return self._p if self._p.id == pid else None def find_by_id(self, pid): return self._p if self._p.id == pid else None class _StubLibraryRepo: def __init__(self, lib): self._lib = lib def find_by_project(self, pid, kind=None): if self._lib.project_id == pid: return [self._lib] return [] _FIXTURE_PROJECT = Project(id="p-1", owner_user_id="user-1", name="proj", description="") _FIXTURE_LIBRARY = AssetLibrary( id="lib-1", project_id="p-1", name="videos", kind=AssetLibraryKind.VIDEO, asset_count=0, total_size=0 ) def _storage(): s = MagicMock() s.create_direct_upload_post.return_value = { "url": "https://bucket.oss.example.com", "method": "POST", "storage_key": "uploads/abc/test.mp4", "expires_at": "2026-01-01T00:00:00Z", "fields": {"key": "uploads/abc/test.mp4"}, } return s # --------------------------------------------------------------------------- # 场景 1:第一次上传(无 file_hash) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_prepare_first_upload_no_hash_returns_no_dedup(): repo = _FakeAssetRepo() req = SimpleNamespace( project_id="p-1", library_id="lib-1", filename="test.mp4", content_type="video/mp4", file_size=1024, file_hash="", client_upload_id="", ) resp = await upload_route.prepare_direct_upload( request=req, authenticated_user=_user(), project_repository=_StubProjectRepo(_FIXTURE_PROJECT), asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY), asset_repository=repo, storage_service=_storage(), ) assert resp.duplicated is False assert resp.skip_transfer is False assert resp.asset_id == "" # file_hash 空,不预建 assert repo.saved == 0 # --------------------------------------------------------------------------- # 场景 2:第一次上传带 file_hash → duplicated=false + asset_id 非空 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_prepare_first_upload_with_hash_creates_pending(): repo = _FakeAssetRepo() req = SimpleNamespace( project_id="p-1", library_id="lib-1", filename="test.mp4", content_type="video/mp4", file_size=1024, file_hash="abc123", client_upload_id="", ) resp = await upload_route.prepare_direct_upload( request=req, authenticated_user=_user(), project_repository=_StubProjectRepo(_FIXTURE_PROJECT), asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY), asset_repository=repo, storage_service=_storage(), ) assert resp.duplicated is False assert resp.skip_transfer is False assert resp.asset_id != "" # 预建记录确实落库 assert repo.saved == 1 pending = repo.find_by_id(resp.asset_id) assert pending is not None assert pending.file_hash == "abc123" assert pending.status == AssetStatus.PROCESSING # --------------------------------------------------------------------------- # 场景 3:第二次同 hash → duplicated=true, skip_transfer=true # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_prepare_second_upload_same_hash_returns_duplicated(): repo = _FakeAssetRepo() repo.create(_make_pending(file_hash="abc123", id="existing-asset")) req = SimpleNamespace( project_id="p-1", library_id="lib-1", filename="test.mp4", content_type="video/mp4", file_size=1024, file_hash="abc123", client_upload_id="", ) resp = await upload_route.prepare_direct_upload( request=req, authenticated_user=_user(), project_repository=_StubProjectRepo(_FIXTURE_PROJECT), asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY), asset_repository=repo, storage_service=_storage(), ) assert resp.duplicated is True assert resp.skip_transfer is True assert resp.asset_id == "existing-asset" assert resp.upload_url == "" # 未签名 OSS # 未新增记录 assert repo.saved == 1 # 只有初始那条 # --------------------------------------------------------------------------- # 场景 4:同 client_upload_id 重试 → 直接跳过 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_prepare_retry_same_client_upload_id_skips(): repo = _FakeAssetRepo() repo.create( _make_pending( file_hash="abc123", client_upload_id="cuid-xyz", id="existing-asset", ) ) # 即使 file_hash 不同(理论上不会),client_upload_id 命中也直接跳过 req = SimpleNamespace( project_id="p-1", library_id="lib-1", filename="test.mp4", content_type="video/mp4", file_size=1024, file_hash="different-hash", client_upload_id="cuid-xyz", ) resp = await upload_route.prepare_direct_upload( request=req, authenticated_user=_user(), project_repository=_StubProjectRepo(_FIXTURE_PROJECT), asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY), asset_repository=repo, storage_service=_storage(), ) assert resp.duplicated is True assert resp.skip_transfer is True assert resp.asset_id == "existing-asset" # --------------------------------------------------------------------------- # 兜底:文件名兜底去重不误命中 PROCESSING 占位 # --------------------------------------------------------------------------- def test_filename_fallback_does_not_match_processing_pending(): """_find_duplicate_asset 按文件名兜底时,不能命中 pre-create 的 PROCESSING 记录。""" repo = _FakeAssetRepo() repo.create(_make_pending(id="p1")) result = upload_route._find_duplicate_asset( repo, library_id="lib-1", file_hash="", # 无 hash client_upload_id="", # 无 cuid filename="test.mp4", # 同名 file_size=1024, ) assert result is None # PROCESSING 占位不被兜底命中 def test_filename_fallback_matches_stable_ready_record(): """READY 状态的已存在记录能被文件名兜底命中。""" repo = _FakeAssetRepo() repo.create(_make_asset(status=AssetStatus.READY, id="ready-asset")) # 伪造 find_recent_active_by_library_and_name 返回 READY 记录 repo.find_recent_active_by_library_and_name = lambda **kw: repo.assets["ready-asset"] result = upload_route._find_duplicate_asset( repo, library_id="lib-1", file_hash="", client_upload_id="", filename="existing.mp4", file_size=1024, ) assert result is not None assert result.id == "ready-asset" # --------------------------------------------------------------------------- # _create_pending_asset find-or-create # --------------------------------------------------------------------------- def test_create_pending_asset_reuses_existing_by_hash(): """_create_pending_asset:file_hash 命中现有 PROCESSING 记录则复用,不新建。""" repo = _FakeAssetRepo() repo.create(_make_pending(file_hash="abc123", client_upload_id="", id="p1")) # 复用 result = upload_route._create_pending_asset( asset_repository=repo, project_id="p-1", library_id="lib-1", storage_key="uploads/new/test.mp4", filename="test.mp4", mime_type="video/mp4", user_id="user-1", file_hash="abc123", client_upload_id="cuid-new", ) assert result.id == "p1" assert repo.saved == 1 # 没新增 assert repo.updated >= 1 # 字段补齐触发 update assert result.client_upload_id == "cuid-new" def test_create_pending_asset_creates_when_no_match(): """无匹配时正常新建。""" repo = _FakeAssetRepo() result = upload_route._create_pending_asset( asset_repository=repo, project_id="p-1", library_id="lib-1", storage_key="uploads/new/test.mp4", filename="test.mp4", mime_type="video/mp4", user_id="user-1", file_hash="newhash", client_upload_id="newcuid", ) assert result.id != "" assert result.file_hash == "newhash" assert result.client_upload_id == "newcuid" assert repo.saved == 1 # --------------------------------------------------------------------------- # 兜底去重:PROCESSING 占位 hash 不同时跳过 # --------------------------------------------------------------------------- def test_filename_fallback_skips_processing_with_different_hash(): """PROCESSING/UPLOADING 占位记录仅当 hash 一致(或占位无 hash)才命中;hash 不同跳过。""" repo = _FakeAssetRepo() repo.create(_make_pending(id="p1", file_hash="oldhash")) repo.find_recent_active_by_library_and_name = lambda **kw: repo.assets["p1"] result = upload_route._find_duplicate_asset( repo, library_id="lib-1", file_hash="differenthash", # 新上传内容不同 client_upload_id="", filename="test.mp4", file_size=1024, ) assert result is None def test_filename_fallback_matches_processing_with_same_hash(): """PROCESSING 占位 hash 与请求一致时命中(重试场景)。""" repo = _FakeAssetRepo() repo.create(_make_pending(id="p1", file_hash="samehash")) repo.find_recent_active_by_library_and_name = lambda **kw: repo.assets["p1"] result = upload_route._find_duplicate_asset( repo, library_id="lib-1", file_hash="samehash", client_upload_id="", filename="test.mp4", file_size=1024, ) assert result is not None assert result.id == "p1" # --------------------------------------------------------------------------- # prepare 预建失败降级:不阻塞签名 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_prepare_pending_asset_create_failure_degrades_gracefully(): """预建 asset 抛异常时,prepare 仍正常返回签名(duplicated=False, asset_id 空)。""" class _BrokenRepo(_FakeAssetRepo): def create(self, asset): raise RuntimeError("db down") repo = _BrokenRepo() req = SimpleNamespace( project_id="p-1", library_id="lib-1", filename="test.mp4", content_type="video/mp4", file_size=1024, file_hash="abc123", client_upload_id="cuid-1", ) resp = await upload_route.prepare_direct_upload( request=req, authenticated_user=_user(), project_repository=_StubProjectRepo(_FIXTURE_PROJECT), asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY), asset_repository=repo, storage_service=_storage(), ) assert resp.duplicated is False assert resp.skip_transfer is False assert resp.asset_id == "" # 预建失败,降级无 asset_id assert resp.upload_url != "" # 签名仍正常返回 def test_create_pending_asset_update_failure_swallowed(): """复用占位记录时字段补齐 update 抛异常被吞掉,不阻塞返回。""" class _UpdateBrokenRepo(_FakeAssetRepo): def update(self, asset): raise RuntimeError("db down") repo = _UpdateBrokenRepo() repo.create(_make_pending(file_hash="abc123", client_upload_id="", id="p1")) result = upload_route._create_pending_asset( asset_repository=repo, project_id="p-1", library_id="lib-1", storage_key="uploads/new/test.mp4", filename="test.mp4", mime_type="video/mp4", user_id="user-1", file_hash="abc123", client_upload_id="cuid-new", file_size=1024, ) assert result.id == "p1" # 仍复用,不抛异常 assert repo.saved == 1