test: 补充表单上传端点单元测试 #123

Merged
xiaoxia merged 2 commits from test/unit-tests-core-modules into develop 2026-06-30 15:35:46 +08:00
2 changed files with 652 additions and 3 deletions
+2 -3
View File
@@ -199,7 +199,8 @@ async def complete_direct_upload(
description="上传素材文件(multipart/form-data),支持视频、音频、图片。触发导入流水线自动处理。",
)
async def upload_asset(
form_data: Annotated[UploadAssetRequest, Form()],
project_id: str = Form(..., min_length=1, description="项目 ID"),
library_id: str = Form(..., min_length=1, description="素材库 ID"),
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
@@ -208,8 +209,6 @@ async def upload_asset(
storage_service: OSSStorageService = Depends(get_storage_service),
) -> UploadAssetResponse:
"""上传素材文件并触发导入流水线。"""
project_id = form_data.project_id
library_id = form_data.library_id
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
# P2-5: 服务端验证 MIME 类型
+650
View File
@@ -0,0 +1,650 @@
"""
upload.py 表单上传端点单元测试
覆盖(24个测试用例):
- 正常路径(5):视频/音频/图片上传成功、创建导入任务、响应包含URL
- 文件名校验(5):路径遍历防护、反斜杠处理、空文件名、特殊字符、无扩展名
- 异常路径(8):不支持文件类型、项目/素材库不存在、存储服务错误、缺少参数
- 多格式支持(3):多种视频(4种)/音频(5种)/图片(6种)格式
- MIME验证(6):有效类型、空类型(400)、不支持类型(415)
"""
from __future__ import annotations
import io
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 __init__(self):
self._jobs = {}
self._counter = 0
def add(self, job) -> None:
self._jobs[job.id] = job
def get(self, job_id: str):
return self._jobs.get(job_id)
def update_status(self, job_id, status, **kwargs):
if job_id in self._jobs:
self._jobs[job_id].status = status
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
def create(self, job):
"""创建一个模拟的 ingest job"""
self._jobs[job.id] = job
return job
# ---------------------------------------------------------------------------
# 测试 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.upload_file.return_value = "https://bucket.oss.example.com/uploads/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 TestFormUploadSuccess:
"""表单上传正常成功路径测试。"""
def test_upload_video_file_successfully(self):
"""上传视频文件成功。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
ingest_repo = StubIngestJobRepository()
storage = MagicMock()
storage.upload_file.return_value = "https://bucket.oss.example.com/uploads/video.mp4"
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
ingest_repo=ingest_repo,
)
# 模拟一个视频文件
file_content = b"fake video content" * 100
files = {"file": ("test-video.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 200
result = resp.json()
assert "storage_key" in result
assert "ingest_job_id" in result
assert "url" in result
assert storage.upload_file.called
def test_upload_image_file_successfully(self):
"""上传图片文件成功。"""
project = _make_project()
library = _make_library(kind=AssetLibraryKind.IMAGE)
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.return_value = "https://bucket.oss.example.com/uploads/image.jpg"
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
# 模拟一个图片文件
file_content = b"fake image content" * 50
files = {"file": ("test-image.jpg", io.BytesIO(file_content), "image/jpeg")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 200
assert storage.upload_file.called
def test_upload_audio_file_successfully(self):
"""上传音频文件成功。"""
project = _make_project()
library = _make_library(kind=AssetLibraryKind.VOICE)
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.return_value = "https://bucket.oss.example.com/uploads/audio.mp3"
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake audio content" * 50
files = {"file": ("test-audio.mp3", io.BytesIO(file_content), "audio/mpeg")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 200
assert storage.upload_file.called
def test_ingest_job_is_created(self):
"""验证上传成功后创建导入任务。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
ingest_repo = StubIngestJobRepository()
client = _client(
project_repo=project_repo,
library_repo=library_repo,
ingest_repo=ingest_repo,
)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 200
result = resp.json()
assert "ingest_job_id" in result
# 验证 ingest job 被创建
job = ingest_repo.get(result["ingest_job_id"])
assert job is not None
def test_response_contains_url(self):
"""验证响应包含文件 URL。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
expected_url = "https://bucket.oss.example.com/uploads/my-video.mp4"
storage.upload_file.return_value = expected_url
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 200
result = resp.json()
assert "url" in result
assert result["url"].startswith("https://")
class TestFormUploadFilenameSanitization:
"""文件名校验测试。"""
def _upload_with_filename(self, filename: str):
"""辅助方法:使用指定文件名上传文件。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.return_value = "https://bucket.oss.example.com/uploads/test.mp4"
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake video content"
files = {"file": (filename, io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
return resp, storage
def test_path_traversal_prevented(self):
"""路径遍历防护:../ 被替换为 __。"""
resp, storage = self._upload_with_filename("../../../etc/passwd")
assert resp.status_code == 200
# 验证文件名被清理
call_args = storage.upload_file.call_args
storage_key = call_args[0][1] # 第二个位置参数是 storage_key
# 提取文件名部分(去掉 uploads/{file_id}/ 前缀)
filename_part = storage_key.split("/", 2)[-1]
# 文件名部分不应包含 / 或 \
assert "/" not in filename_part
assert "\\" not in filename_part
def test_backslash_replaced(self):
"""反斜杠被替换为下划线。"""
resp, storage = self._upload_with_filename(r"folder\subfolder\video.mp4")
assert resp.status_code == 200
call_args = storage.upload_file.call_args
storage_key = call_args[0][1]
assert "\\" not in storage_key
def test_empty_filename_becomes_unknown(self):
"""空文件名被 FastAPI 拒绝(422)。"""
resp, storage = self._upload_with_filename("")
# FastAPI 验证文件名不能为空
assert resp.status_code == 422
def test_special_characters_in_filename(self):
"""特殊字符文件名正常处理。"""
resp, storage = self._upload_with_filename("my-video (2024) [HD].mp4")
assert resp.status_code == 200
call_args = storage.upload_file.call_args
storage_key = call_args[0][1]
assert "my-video (2024) [HD].mp4" in storage_key
def test_filename_without_extension(self):
"""无扩展名文件名正常处理。"""
resp, storage = self._upload_with_filename("no-extension-file")
assert resp.status_code == 200
call_args = storage.upload_file.call_args
storage_key = call_args[0][1]
assert "no-extension-file" in storage_key
class TestFormUploadMissingFile:
"""缺少文件字段测试。"""
def test_missing_file_field_returns_422(self):
"""缺少文件字段时返回 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)
# 只提交表单数据,不上传文件
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", data=data)
assert resp.status_code == 422
class TestFormUploadMissingParameters:
"""缺少必填参数测试。"""
def test_missing_project_id_returns_422(self):
"""缺少 project_id 时返回 422。"""
library = _make_library()
library_repo = StubAssetLibraryRepository({library.id: library})
client = _client(library_repo=library_repo)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"library_id": library.id} # 缺少 project_id
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 422
def test_missing_library_id_returns_422(self):
"""缺少 library_id 时返回 422。"""
project = _make_project()
project_repo = StubProjectRepository({project.id: project})
client = _client(project_repo=project_repo)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id} # 缺少 library_id
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 422
def test_empty_project_id_returns_422(self):
"""project_id 为空字符串时返回 422min_length=1)。"""
library = _make_library()
library_repo = StubAssetLibraryRepository({library.id: library})
client = _client(library_repo=library_repo)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": "", "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 422
class TestFormUploadProjectNotFound:
"""项目/素材库不存在测试。"""
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_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": "nonexistent", "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
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_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": "nonexistent-lib"}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 404
assert "Asset library not found" in resp.json()["detail"]
class TestFormUploadOSSNotConfigured:
"""OSS 未配置测试。"""
def test_returns_503_when_oss_not_configured(self):
"""OSS 未配置时返回 503RuntimeError)。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.side_effect = RuntimeError("OSS 未配置")
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 503
class TestFormUploadStorageErrors:
"""存储服务错误测试。"""
def test_returns_500_on_generic_storage_error(self):
"""存储服务通用错误返回 500。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.side_effect = Exception("Network error")
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake video content"
files = {"file": ("test.mp4", io.BytesIO(file_content), "video/mp4")}
data = {"project_id": project.id, "library_id": library.id}
resp = client.post("/api/v1", files=files, data=data)
assert resp.status_code == 500
assert "Failed to upload file" in resp.json()["detail"]
class TestFormUploadMultipleFormats:
"""多格式支持测试。"""
def _upload_with_mime(self, mime_type: str, filename: str = "test.mp4"):
"""辅助方法:使用指定 MIME 类型上传文件。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.return_value = "https://bucket.oss.example.com/uploads/test.mp4"
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake content"
files = {"file": (filename, io.BytesIO(file_content), mime_type)}
data = {"project_id": project.id, "library_id": library.id}
return client.post("/api/v1", files=files, data=data)
def test_multiple_video_formats(self):
"""支持多种视频格式(4种)。"""
video_formats = [
("video/mp4", "test.mp4"),
("video/mpeg", "test.mpeg"),
("video/quicktime", "test.mov"),
("video/x-msvideo", "test.avi"),
]
for mime_type, filename in video_formats:
resp = self._upload_with_mime(mime_type, filename)
assert resp.status_code == 200, f"Failed for {mime_type}"
def test_multiple_audio_formats(self):
"""支持多种音频格式(5种)。"""
audio_formats = [
("audio/mpeg", "test.mp3"),
("audio/wav", "test.wav"),
("audio/ogg", "test.ogg"),
("audio/flac", "test.flac"),
("audio/aac", "test.aac"),
]
for mime_type, filename in audio_formats:
resp = self._upload_with_mime(mime_type, filename)
assert resp.status_code == 200, f"Failed for {mime_type}"
def test_multiple_image_formats(self):
"""支持多种图片格式(6种)。"""
image_formats = [
("image/jpeg", "test.jpg"),
("image/png", "test.png"),
("image/gif", "test.gif"),
("image/webp", "test.webp"),
("image/bmp", "test.bmp"),
("image/svg+xml", "test.svg"),
]
for mime_type, filename in image_formats:
resp = self._upload_with_mime(mime_type, filename)
assert resp.status_code == 200, f"Failed for {mime_type}"
class TestFormUploadMIMEValidation:
"""MIME 类型验证测试。"""
def _upload_with_content_type(self, content_type: str | None):
"""辅助方法:使用指定 Content-Type 上传文件。"""
project = _make_project()
library = _make_library()
project_repo = StubProjectRepository({project.id: project})
library_repo = StubAssetLibraryRepository({library.id: library})
storage = MagicMock()
storage.upload_file.return_value = "https://bucket.oss.example.com/uploads/test.mp4"
client = _client(
project_repo=project_repo,
library_repo=library_repo,
storage=storage,
)
file_content = b"fake content"
# 使用元组形式明确指定 content_type
files = {"file": ("test.mp4", io.BytesIO(file_content), content_type)}
data = {"project_id": project.id, "library_id": library.id}
return client.post("/api/v1", files=files, data=data)
def test_valid_mime_type_accepted(self):
"""有效 MIME 类型被接受。"""
resp = self._upload_with_content_type("video/mp4")
assert resp.status_code == 200
def test_empty_content_type_returns_400(self):
"""空 Content-Type 返回 400。"""
# 使用空字符串作为 content type
resp = self._upload_with_content_type("")
assert resp.status_code == 400
assert "Content-Type header is required" in resp.json()["detail"]
def test_unsupported_content_type_returns_415(self):
"""不支持的 Content-Type 返回 415。"""
resp = self._upload_with_content_type("text/plain")
assert resp.status_code == 415
assert "not supported" in resp.json()["detail"]