70dde8cbfb
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 27s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 27s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m2s
AI Code Review / AI Code Review (pull_request) Failing after 1m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m7s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 31s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m15s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m47s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m45s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m18s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m42s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m47s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 4m42s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m33s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m15s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m12s
CI/CD Pipeline / Unit Tests (push) Successful in 10m53s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
472 lines
15 KiB
Python
472 lines
15 KiB
Python
"""#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
|