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
391 lines
13 KiB
Python
391 lines
13 KiB
Python
"""
|
|
upload.py 路由单元测试
|
|
|
|
覆盖:
|
|
- _require_project_and_library 中 find_by_project 调用正确性
|
|
- OSS 凭证校验(未配置时返回 503)
|
|
- prepare_direct_upload 正常路径
|
|
- 文件类型校验
|
|
- 异常处理路径
|
|
"""
|
|
|
|
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,因为 Port 定义 async 方法,路由实际使用同步 duck-type)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 测试 Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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",
|
|
kind: AssetLibraryKind = AssetLibraryKind.VIDEO,
|
|
) -> AssetLibrary:
|
|
return AssetLibrary(id=id, name="Test Library", project_id=project_id, kind=kind)
|
|
|
|
|
|
def _build_app(
|
|
project_repo: StubProjectRepository | None = None,
|
|
library_repo: StubAssetLibraryRepository | None = None,
|
|
storage: MagicMock | None = None,
|
|
ingest_repo: StubIngestJobRepository | None = None,
|
|
) -> FastAPI:
|
|
"""构建一个最小化的 FastAPI app,只注册 upload 路由。"""
|
|
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_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()
|
|
storage = storage or MagicMock()
|
|
storage.is_configured = True
|
|
storage.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"},
|
|
}
|
|
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[get_project_repository] = lambda: project_repo
|
|
app.dependency_overrides[get_asset_library_repository] = lambda: library_repo
|
|
app.dependency_overrides[get_storage_service] = lambda: storage
|
|
app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
|
|
|
return app
|
|
|
|
|
|
def _client(**kwargs) -> TestClient:
|
|
app = _build_app(**kwargs)
|
|
return TestClient(app)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 测试用例
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRequireProjectAndLibrary:
|
|
"""_require_project_and_library 辅助函数测试。"""
|
|
|
|
def test_returns_200_when_project_and_library_exist(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)
|
|
|
|
resp = client.post(
|
|
"/api/v1/direct/prepare",
|
|
json={
|
|
"project_id": project.id,
|
|
"library_id": library.id,
|
|
"filename": "test.mp4",
|
|
"content_type": "video/mp4",
|
|
"file_size": 1024,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
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,
|
|
)
|
|
|
|
resp = client.post(
|
|
"/api/v1/direct/prepare",
|
|
json={
|
|
"project_id": "nonexistent",
|
|
"library_id": library.id,
|
|
"filename": "test.mp4",
|
|
"content_type": "video/mp4",
|
|
"file_size": 1024,
|
|
},
|
|
)
|
|
assert resp.status_code == 404
|
|
assert "Project not found" in resp.json()["detail"]
|
|
|
|
def test_returns_404_when_library_not_found(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)
|
|
|
|
resp = client.post(
|
|
"/api/v1/direct/prepare",
|
|
json={
|
|
"project_id": project.id,
|
|
"library_id": "nonexistent-lib",
|
|
"filename": "test.mp4",
|
|
"content_type": "video/mp4",
|
|
"file_size": 1024,
|
|
},
|
|
)
|
|
assert resp.status_code == 404
|
|
assert "Asset library not found" in resp.json()["detail"]
|
|
|
|
def test_find_by_project_is_called_not_list_by_project(self):
|
|
"""
|
|
验证路由调用的是 find_by_project 而不是 list_by_project。
|
|
StubAssetLibraryRepository.list_by_project 会抛出 AssertionError。
|
|
"""
|
|
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/direct/prepare",
|
|
json={
|
|
"project_id": project.id,
|
|
"library_id": library.id,
|
|
"filename": "test.mp4",
|
|
"content_type": "video/mp4",
|
|
"file_size": 1024,
|
|
},
|
|
)
|
|
# 如果调用了 list_by_project,会抛 AssertionError 导致 500
|
|
assert resp.status_code == 200
|
|
|
|
|
|
class TestPrepareDirectUpload:
|
|
"""prepare_direct_upload 端点测试。"""
|
|
|
|
def test_returns_upload_credentials_when_configured(self):
|
|
"""OSS 已配置时,返回上传凭证。"""
|
|
project = _make_project()
|
|
library = _make_library()
|
|
project_repo = StubProjectRepository({project.id: project})
|
|
library_repo = StubAssetLibraryRepository({library.id: library})
|
|
|
|
storage = MagicMock()
|
|
storage.is_configured = True
|
|
storage.create_direct_upload_post.return_value = {
|
|
"url": "https://bucket.oss.example.com",
|
|
"method": "POST",
|
|
"storage_key": "uploads/abc/test-video.mp4",
|
|
"expires_at": "2026-01-01T00:00:00Z",
|
|
"fields": {"key": "uploads/abc/test-video.mp4"},
|
|
}
|
|
|
|
client = _client(
|
|
project_repo=project_repo,
|
|
library_repo=library_repo,
|
|
storage=storage,
|
|
)
|
|
|
|
resp = client.post(
|
|
"/api/v1/direct/prepare",
|
|
json={
|
|
"project_id": project.id,
|
|
"library_id": library.id,
|
|
"filename": "test-video.mp4",
|
|
"content_type": "video/mp4",
|
|
"file_size": 1024 * 1024,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "upload_url" in data
|
|
assert "storage_key" in data
|
|
|
|
def test_returns_503_when_oss_not_configured(self):
|
|
"""OSS 未配置时,返回 503。"""
|
|
project = _make_project()
|
|
library = _make_library()
|
|
project_repo = StubProjectRepository({project.id: project})
|
|
library_repo = StubAssetLibraryRepository({library.id: library})
|
|
|
|
storage = MagicMock()
|
|
storage.create_direct_upload_post.side_effect = RuntimeError("OSS 未配置")
|
|
|
|
client = _client(
|
|
project_repo=project_repo,
|
|
library_repo=library_repo,
|
|
storage=storage,
|
|
)
|
|
|
|
resp = client.post(
|
|
"/api/v1/direct/prepare",
|
|
json={
|
|
"project_id": project.id,
|
|
"library_id": library.id,
|
|
"filename": "test.mp4",
|
|
"content_type": "video/mp4",
|
|
"file_size": 1024,
|
|
},
|
|
)
|
|
assert resp.status_code == 503
|
|
|
|
|
|
class TestCompleteDirectUpload:
|
|
"""complete_direct_upload 端点测试。"""
|
|
|
|
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,
|
|
)
|
|
|
|
resp = client.post(
|
|
"/api/v1/direct/complete",
|
|
json={
|
|
"project_id": "nonexistent",
|
|
"library_id": library.id,
|
|
"storage_key": "uploads/abc/test.mp4",
|
|
"filename": "test.mp4",
|
|
"content_type": "video/mp4",
|
|
"file_size": 1024,
|
|
},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestMimeTypeValidation:
|
|
"""文件类型校验测试。"""
|
|
|
|
def test_accepts_video_mp4(self):
|
|
"""video/mp4 是合法类型。"""
|
|
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/direct/prepare",
|
|
json={
|
|
"project_id": project.id,
|
|
"library_id": library.id,
|
|
"filename": "test.mp4",
|
|
"content_type": "video/mp4",
|
|
"file_size": 1024,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
def test_rejects_invalid_mime_type(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)
|
|
|
|
resp = client.post(
|
|
"/api/v1/direct/prepare",
|
|
json={
|
|
"project_id": project.id,
|
|
"library_id": library.id,
|
|
"filename": "malware.exe",
|
|
"content_type": "application/x-executable",
|
|
"file_size": 1024,
|
|
},
|
|
)
|
|
assert resp.status_code == 415
|