Files
xiaoxia-saas/tests/unit/test_asset_dedup.py
xiaoxia 161c1a61b6
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m11s
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 / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m24s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m35s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m20s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 52s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m22s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 3m7s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m25s
CI/CD Pipeline / Unit Tests (push) Successful in 12m58s
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m59s
CI/CD Pipeline / CI Gate (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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 36s
AI Code Review / AI Code Review (pull_request) Successful in 38s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 33s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m45s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m12s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m35s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m4s
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 / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m56s
CI/CD Pipeline / CI Gate (pull_request) Successful in 8s
fix: HEVC 转码条件解耦 + 缩略图函数补全 + extra_meta 字段 (#1452)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-20 21:41:01 +08:00

343 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
素材重复上传检测 单元测试
覆盖:
- 表单上传(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")
storage.get_url = 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 completePOST /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