52ff2f80ad
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
317 lines
10 KiB
Python
317 lines
10 KiB
Python
"""
|
||
chunked_upload.py 路由单元测试
|
||
|
||
覆盖:
|
||
- init_chunked_upload 端点正常路径
|
||
- init_chunked_upload 项目/素材库不存在时返回 404
|
||
- find_by_project 调用正确性
|
||
- 文件大小校验
|
||
- OSS 凭证校验
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock
|
||
|
||
# 设置必要环境变量(必须在导入 app 模块之前)
|
||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||
|
||
import pytest
|
||
|
||
# 确保 app 模块可导入
|
||
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 AssetLibrary, AssetLibraryKind, Project
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Stub 实现(不继承 Port ABC)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class StubProjectRepository:
|
||
def __init__(self, projects: dict[str, Project] | None = None):
|
||
self._projects = projects or {}
|
||
|
||
def get(self, project_id: str) -> Project | None:
|
||
return self._projects.get(project_id)
|
||
|
||
def find_by_id(self, project_id: str) -> Project | None:
|
||
return self._projects.get(project_id)
|
||
|
||
|
||
class StubAssetLibraryRepository:
|
||
def __init__(self, libraries: dict[str, AssetLibrary] | None = None):
|
||
self._libraries = libraries or {}
|
||
|
||
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
|
||
|
||
def list_by_project(self, project_id: str) -> list[AssetLibrary]:
|
||
raise AssertionError("路由不应调用 list_by_project,应调用 find_by_project")
|
||
|
||
|
||
class StubChunkedUploadRepository:
|
||
def __init__(self):
|
||
self._uploads = {}
|
||
|
||
def add(self, upload) -> None:
|
||
self._uploads[upload.upload_id] = upload
|
||
|
||
def get(self, upload_id: str):
|
||
return self._uploads.get(upload_id)
|
||
|
||
def update(self, upload) -> None:
|
||
self._uploads[upload.upload_id] = upload
|
||
|
||
def find_by_project(self, project_id: str, skip: int = 0, limit: int = 50):
|
||
return [u for u in self._uploads.values() if u.project_id == project_id]
|
||
|
||
def count_by_project(self, project_id: str) -> int:
|
||
return len([u for u in self._uploads.values() if u.project_id == project_id])
|
||
|
||
|
||
class StubIngestJobRepository:
|
||
def add(self, job) -> None:
|
||
pass
|
||
|
||
def get(self, job_id: str):
|
||
return None
|
||
|
||
def update_status(self, job_id, status, **kwargs):
|
||
pass
|
||
|
||
def list_by_library(self, library_id: str, skip: int = 0, limit: int = 50):
|
||
return []
|
||
|
||
def count_by_library(self, library_id: str) -> int:
|
||
return 0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
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 _dep(name: str):
|
||
from app import dependencies
|
||
|
||
return getattr(dependencies, name)
|
||
|
||
|
||
def _build_app(
|
||
project_repo=None,
|
||
library_repo=None,
|
||
storage=None,
|
||
chunked_repo=None,
|
||
ingest_repo=None,
|
||
) -> FastAPI:
|
||
from app.api.routes.chunked_upload import router
|
||
from app.auth import AuthenticatedUser, get_current_user
|
||
from app.core.storage import get_storage_service
|
||
|
||
app = FastAPI()
|
||
app.include_router(router, prefix="/api/v1")
|
||
|
||
project_repo = project_repo or StubProjectRepository()
|
||
library_repo = library_repo or StubAssetLibraryRepository()
|
||
storage = storage or MagicMock()
|
||
storage.is_configured = True
|
||
chunked_repo = chunked_repo or StubChunkedUploadRepository()
|
||
ingest_repo = ingest_repo or StubIngestJobRepository()
|
||
|
||
# Mock auth
|
||
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[_dep("get_project_repository")] = lambda: project_repo
|
||
app.dependency_overrides[_dep("get_asset_library_repository")] = lambda: library_repo
|
||
app.dependency_overrides[get_storage_service] = lambda: storage
|
||
app.dependency_overrides[_dep("get_ingest_job_repository")] = lambda: ingest_repo
|
||
|
||
return app
|
||
|
||
|
||
def _client(**kwargs) -> TestClient:
|
||
return TestClient(_build_app(**kwargs))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 测试用例
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestInitChunkedUpload:
|
||
"""init_chunked_upload 端点测试。"""
|
||
|
||
def test_returns_upload_record_on_success(self):
|
||
"""正常初始化分片上传。"""
|
||
project = _make_project()
|
||
library = _make_library()
|
||
project_repo = StubProjectRepository({project.id: project})
|
||
library_repo = StubAssetLibraryRepository({library.id: library})
|
||
|
||
client = _client(project_repo=project_repo, library_repo=library_repo)
|
||
|
||
file_size = 100 * 1024 * 1024 # 100MB
|
||
chunk_size = 5 * 1024 * 1024 # 5MB
|
||
total_chunks = (file_size + chunk_size - 1) // chunk_size
|
||
|
||
resp = client.post(
|
||
"/api/v1/init",
|
||
json={
|
||
"project_id": project.id,
|
||
"library_id": library.id,
|
||
"filename": "large-video.mp4",
|
||
"content_type": "video/mp4",
|
||
"file_size": file_size,
|
||
"total_chunks": total_chunks,
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
data = resp.json()
|
||
assert data["filename"] == "large-video.mp4"
|
||
assert "upload_id" in data
|
||
|
||
def test_returns_404_when_project_not_found(self):
|
||
"""项目不存在时返回 404。"""
|
||
library = _make_library()
|
||
library_repo = StubAssetLibraryRepository({library.id: library})
|
||
|
||
client = _client(
|
||
project_repo=StubProjectRepository(),
|
||
library_repo=library_repo,
|
||
)
|
||
|
||
file_size = 100 * 1024 * 1024
|
||
total_chunks = (file_size + 5 * 1024 * 1024 - 1) // (5 * 1024 * 1024)
|
||
|
||
resp = client.post(
|
||
"/api/v1/init",
|
||
json={
|
||
"project_id": "nonexistent",
|
||
"library_id": library.id,
|
||
"filename": "test.mp4",
|
||
"content_type": "video/mp4",
|
||
"file_size": file_size,
|
||
"total_chunks": total_chunks,
|
||
},
|
||
)
|
||
assert resp.status_code == 404
|
||
assert "Project not found" in resp.json()["detail"]
|
||
|
||
def test_returns_404_when_library_not_in_project(self):
|
||
"""素材库不属于该项目时返回 404。"""
|
||
project = _make_project()
|
||
project_repo = StubProjectRepository({project.id: project})
|
||
|
||
# 素材库属于另一个项目
|
||
other_library = _make_library(project_id="other-project")
|
||
library_repo = StubAssetLibraryRepository({other_library.id: other_library})
|
||
|
||
client = _client(project_repo=project_repo, library_repo=library_repo)
|
||
|
||
file_size = 100 * 1024 * 1024
|
||
total_chunks = (file_size + 5 * 1024 * 1024 - 1) // (5 * 1024 * 1024)
|
||
|
||
resp = client.post(
|
||
"/api/v1/init",
|
||
json={
|
||
"project_id": project.id,
|
||
"library_id": "nonexistent-lib",
|
||
"filename": "test.mp4",
|
||
"content_type": "video/mp4",
|
||
"file_size": file_size,
|
||
"total_chunks": total_chunks,
|
||
},
|
||
)
|
||
assert resp.status_code == 404
|
||
assert "Asset library not found" in resp.json()["detail"]
|
||
|
||
def test_rejects_file_exceeding_max_size(self):
|
||
"""超过 2GB 限制的文件被拒绝(schema 层 le=2GB 会返回 422)。"""
|
||
project = _make_project()
|
||
library = _make_library()
|
||
project_repo = StubProjectRepository({project.id: project})
|
||
library_repo = StubAssetLibraryRepository({library.id: library})
|
||
|
||
client = _client(project_repo=project_repo, library_repo=library_repo)
|
||
|
||
resp = client.post(
|
||
"/api/v1/init",
|
||
json={
|
||
"project_id": project.id,
|
||
"library_id": library.id,
|
||
"filename": "huge.mp4",
|
||
"content_type": "video/mp4",
|
||
"file_size": 3 * 1024 * 1024 * 1024, # 3GB,超过 2GB 限制
|
||
"total_chunks": 600,
|
||
},
|
||
)
|
||
# schema le=2147483648 → 422; route-level check → 413
|
||
assert resp.status_code in (400, 413, 422)
|
||
|
||
def test_find_by_project_is_called_not_list_by_project(self):
|
||
"""验证路由调用 find_by_project 而非 list_by_project。"""
|
||
project = _make_project()
|
||
library = _make_library()
|
||
project_repo = StubProjectRepository({project.id: project})
|
||
library_repo = StubAssetLibraryRepository({library.id: library})
|
||
|
||
client = _client(project_repo=project_repo, library_repo=library_repo)
|
||
|
||
file_size = 100 * 1024 * 1024
|
||
total_chunks = (file_size + 5 * 1024 * 1024 - 1) // (5 * 1024 * 1024)
|
||
|
||
resp = client.post(
|
||
"/api/v1/init",
|
||
json={
|
||
"project_id": project.id,
|
||
"library_id": library.id,
|
||
"filename": "test.mp4",
|
||
"content_type": "video/mp4",
|
||
"file_size": file_size,
|
||
"total_chunks": total_chunks,
|
||
},
|
||
)
|
||
# 如果调用了 list_by_project,StubAssetLibraryRepository 会抛 AssertionError
|
||
assert resp.status_code == 200
|
||
|
||
|
||
class TestChunkedUploadConstants:
|
||
"""分片上传常量测试。"""
|
||
|
||
def test_max_file_size_is_2gb(self):
|
||
from app.api.routes.chunked_upload import MAX_FILE_SIZE
|
||
|
||
assert MAX_FILE_SIZE == 2 * 1024 * 1024 * 1024
|
||
|
||
def test_default_chunk_size_is_5mb(self):
|
||
from app.api.routes.chunked_upload import DEFAULT_CHUNK_SIZE
|
||
|
||
assert DEFAULT_CHUNK_SIZE == 5 * 1024 * 1024
|
||
|
||
def test_chunk_expiry_hours_is_24(self):
|
||
from app.api.routes.chunked_upload import CHUNK_EXPIRY_HOURS
|
||
|
||
assert CHUNK_EXPIRY_HOURS == 24
|