Files
xiaoxia-saas/tests/unit/test_form_upload_routes.py
xiaoxia ec45d71d2c
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker 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 (push) Successful in 7s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 7s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
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 API Image (pull_request) Successful in 31s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 52s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 45s
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 / Production Browser E2E (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 / Staging API Integration Tests (pull_request) Has been skipped
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 / Build Staging Worker Image (push) Successful in 51s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m47s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m47s
CI/CD Pipeline / Validate - Style (push) Successful in 3m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m12s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 3m25s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m45s
AI Code Review / AI Code Review (pull_request) Failing after 3m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m16s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m28s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m24s
CI/CD Pipeline / Validate - Security (push) Successful in 8m20s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m16s
CI/CD Pipeline / Unit Tests (push) Successful in 8m54s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Successful in 20s
CI/CD Pipeline / Build Production API Image (push) Successful in 22s
CI/CD Pipeline / Build Production Web Image (push) Successful in 2m9s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 11m33s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix: 上传素材后视频库立即显示(创建 PROCESSING 状态 Asset) (#1644)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-03 14:56:35 +08:00

675 lines
24 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.
"""
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")
# 确保 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)
class StubAssetRepository:
"""Minimal asset repository stub for upload tests."""
def __init__(self):
self._assets = {}
def create(self, asset):
self._assets[asset.id] = asset
return asset
def find_by_storage_key(self, storage_key):
for a in self._assets.values():
if a.storage_key == storage_key:
return a
return None
def find_by_library_and_file_hash(self, library_id, file_hash):
return None
def _build_app(
project_repo: StubProjectRepository | None = None,
library_repo: StubAssetLibraryRepository | None = None,
storage: MagicMock | None = None,
ingest_repo: StubIngestJobRepository | None = None,
asset_repo: StubAssetRepository | 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_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()
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()
asset_repo = asset_repo or StubAssetRepository()
# Mock auth
mock_user = MagicMock(spec=AuthenticatedUser)
mock_user.id = "user-1"
mock_user.email = "test@example.com"
mock_user.user = MagicMock()
mock_user.user.id = "user-1"
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
app.dependency_overrides[get_asset_repository] = lambda: asset_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"]