""" 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