Files
xiaoxia-saas/tests/unit/test_chunked_upload_routes.py
CI Bot 1a57878f76
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
chore(backend): Phase 3 清理 — 未使用依赖删除 + pyflakes 警告清零 + 测试文件冗余清理
1. 未使用依赖清理:
   - 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL

2. pyflakes 警告清零 (apps/ + packages/ + tests/):
   - 移除 17 处未使用的 import (F401)
   - 修复 26 处未使用的局部变量 (F841):
     * 有副作用的赋值转为裸调用
     * 无副作用的赋值直接删除
   - 修复 1 处未使用的异常变量 (F841)
   - 修复 1 处空 except 块

3. 测试文件冗余清理:
   - 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
   - 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:14:46 +08:00

316 lines
10 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.
"""
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")
# 确保 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_projectStubAssetLibraryRepository 会抛 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