2f64fea7f0
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 3m34s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m51s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 7m14s
CI/CD Pipeline / Frontend Lint (push) Successful in 7m49s
CI/CD Pipeline / Unit Tests (push) Successful in 8m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 9m2s
CI/CD Pipeline / Integration Tests (push) Successful in 2m5s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 10m1s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 4m39s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 5m12s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
274 lines
10 KiB
Python
274 lines
10 KiB
Python
"""
|
||
测试视频上传失败排查修复:
|
||
1. chunked_upload ALLOWED_MIME_TYPES 与 upload.py 保持一致
|
||
2. generated_video_repository list 方法无 N+1 查询
|
||
3. 成片库 API 端点可正常返回数据
|
||
"""
|
||
|
||
import json
|
||
from datetime import datetime, timezone
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
# ─── 1. chunked_upload ALLOWED_MIME_TYPES 一致性 ───
|
||
|
||
|
||
class TestChunkedUploadMIMEConsistency:
|
||
"""chunked_upload.py 的 ALLOWED_MIME_TYPES 必须包含 upload.py 的所有类型。"""
|
||
|
||
def _get_upload_mime_types(self) -> set:
|
||
from apps.api.app.api.routes.upload import ALLOWED_MIME_TYPES
|
||
|
||
return set(ALLOWED_MIME_TYPES)
|
||
|
||
def _get_chunked_mime_types(self) -> set:
|
||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||
|
||
return set(ALLOWED_MIME_TYPES)
|
||
|
||
def test_chunked_upload_has_all_upload_mime_types(self):
|
||
"""chunked_upload 白名单必须覆盖 upload.py 的全部类型。"""
|
||
upload_types = self._get_upload_mime_types()
|
||
chunked_types = self._get_chunked_mime_types()
|
||
missing = upload_types - chunked_types
|
||
assert not missing, f"chunked_upload 缺少以下 MIME 类型: {missing}"
|
||
|
||
def test_chunked_upload_supports_video_mpeg(self):
|
||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||
|
||
assert "video/mpeg" in ALLOWED_MIME_TYPES
|
||
|
||
def test_chunked_upload_supports_video_matroska(self):
|
||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||
|
||
assert "video/x-matroska" in ALLOWED_MIME_TYPES
|
||
|
||
def test_chunked_upload_supports_video_3gpp(self):
|
||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||
|
||
assert "video/3gpp" in ALLOWED_MIME_TYPES
|
||
|
||
def test_chunked_upload_supports_audio_flac(self):
|
||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||
|
||
assert "audio/flac" in ALLOWED_MIME_TYPES
|
||
|
||
def test_chunked_upload_supports_audio_aac(self):
|
||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||
|
||
assert "audio/aac" in ALLOWED_MIME_TYPES
|
||
|
||
def test_chunked_upload_supports_audio_m4a(self):
|
||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||
|
||
assert "audio/x-m4a" in ALLOWED_MIME_TYPES
|
||
|
||
def test_chunked_upload_supports_audio_webm(self):
|
||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||
|
||
assert "audio/webm" in ALLOWED_MIME_TYPES
|
||
|
||
def test_chunked_upload_supports_extra_image_types(self):
|
||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||
|
||
assert "image/bmp" in ALLOWED_MIME_TYPES
|
||
assert "image/tiff" in ALLOWED_MIME_TYPES
|
||
assert "image/svg+xml" in ALLOWED_MIME_TYPES
|
||
|
||
def test_both_have_same_core_video_types(self):
|
||
"""两条路径的核心视频类型必须一致。"""
|
||
upload_types = self._get_upload_mime_types()
|
||
chunked_types = self._get_chunked_mime_types()
|
||
core_video = {"video/mp4", "video/quicktime", "video/webm"}
|
||
for vt in core_video:
|
||
assert vt in upload_types, f"upload.py 缺少 {vt}"
|
||
assert vt in chunked_types, f"chunked_upload.py 缺少 {vt}"
|
||
|
||
|
||
# ─── 2. GeneratedVideo Repository N+1 修复验证 ───
|
||
|
||
|
||
class TestGeneratedVideoRepositoryNoNPlus1:
|
||
"""list_by_project 和 list_by_generation_task 应使用 _to_domain 而非 self.get。"""
|
||
|
||
def _make_model(self, video_id: str, project_id: str = "proj-1", task_id: str = "task-1"):
|
||
model = MagicMock()
|
||
model.id = video_id
|
||
model.project_id = project_id
|
||
model.generation_task_id = task_id
|
||
model.name = f"video-{video_id}.mp4"
|
||
model.file_url = f"https://oss.example.com/{video_id}.mp4"
|
||
model.file_size = 1024
|
||
model.duration = 5.0
|
||
model.thumbnail_url = None
|
||
model.width = 1280
|
||
model.height = 720
|
||
model.fps = 25.0
|
||
model.status = "completed"
|
||
model.review_status = "pending_review"
|
||
model.generation_params = json.dumps({"mode": "one_take"})
|
||
model.video_fingerprint = None
|
||
model.is_duplicate = False
|
||
model.duplicate_of = None
|
||
model.generated_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||
model.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||
return model
|
||
|
||
def test_list_by_project_uses_to_domain(self):
|
||
"""list_by_project 不应调用 self.get(N+1),应使用 _to_domain。"""
|
||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||
SQLAlchemyGeneratedVideoRepository,
|
||
)
|
||
|
||
session = MagicMock()
|
||
models = [self._make_model(f"v{i}") for i in range(5)]
|
||
|
||
query_mock = MagicMock()
|
||
query_mock.filter.return_value.all.return_value = models
|
||
session.query.return_value = query_mock
|
||
|
||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||
with patch.object(SQLAlchemyGeneratedVideoRepository, "get") as mock_get:
|
||
result = repo.list_by_project("proj-1")
|
||
mock_get.assert_not_called()
|
||
|
||
assert len(result) == 5
|
||
assert all(v.id.startswith("v") for v in result)
|
||
|
||
def test_list_by_generation_task_uses_to_domain(self):
|
||
"""list_by_generation_task 不应调用 self.get(N+1),应使用 _to_domain。"""
|
||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||
SQLAlchemyGeneratedVideoRepository,
|
||
)
|
||
|
||
session = MagicMock()
|
||
models = [self._make_model(f"v{i}", task_id="task-42") for i in range(3)]
|
||
|
||
query_mock = MagicMock()
|
||
query_mock.filter.return_value.all.return_value = models
|
||
session.query.return_value = query_mock
|
||
|
||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||
with patch.object(SQLAlchemyGeneratedVideoRepository, "get") as mock_get:
|
||
result = repo.list_by_generation_task("task-42")
|
||
mock_get.assert_not_called()
|
||
|
||
assert len(result) == 3
|
||
|
||
def test_list_by_project_returns_empty_when_no_videos(self):
|
||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||
SQLAlchemyGeneratedVideoRepository,
|
||
)
|
||
|
||
session = MagicMock()
|
||
query_mock = MagicMock()
|
||
query_mock.filter.return_value.all.return_value = []
|
||
session.query.return_value = query_mock
|
||
|
||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||
result = repo.list_by_project("empty-project")
|
||
assert result == []
|
||
|
||
|
||
# ─── 3. 成片库 Schema 及替代端点可用性确认 ───
|
||
|
||
|
||
class TestGeneratedVideosSchemaAndAlternativeEndpoints:
|
||
"""确认成片库 Schema 字段完整,替代端点(generation_tasks)可用。"""
|
||
|
||
def test_generated_video_response_schema_complete(self):
|
||
"""GeneratedVideoResponse 包含所有必要字段。"""
|
||
from apps.api.app.schemas.generated_video import GeneratedVideoResponse
|
||
|
||
fields = GeneratedVideoResponse.model_fields
|
||
required_fields = [
|
||
"id",
|
||
"project_id",
|
||
"name",
|
||
"file_url",
|
||
"status",
|
||
"review_status",
|
||
"download_url",
|
||
]
|
||
for field in required_fields:
|
||
assert field in fields, f"GeneratedVideoResponse 缺少字段: {field}"
|
||
|
||
def test_list_generated_videos_response_schema(self):
|
||
"""ListGeneratedVideosResponse 包含 items 列表。"""
|
||
from apps.api.app.schemas.generated_video import ListGeneratedVideosResponse
|
||
|
||
fields = ListGeneratedVideosResponse.model_fields
|
||
assert "items" in fields, "ListGeneratedVideosResponse 缺少 items 字段"
|
||
|
||
def test_generation_task_results_endpoint_exists(self):
|
||
"""GET /generation/tasks/{task_id}/results 端点存在。"""
|
||
from apps.api.app.api.routes.generation_tasks import router
|
||
|
||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||
assert "/tasks/{task_id}/results" in paths, "generation results 端点不存在"
|
||
|
||
|
||
# ─── 4. GeneratedVideo Use Cases 可用性 ───
|
||
|
||
|
||
class TestGeneratedVideoUseCases:
|
||
"""确认成片库 Use Case 层可正常工作。"""
|
||
|
||
def _make_video(self, video_id: str, project_id: str = "proj-1"):
|
||
from packages.domain import GeneratedVideo
|
||
|
||
return GeneratedVideo(
|
||
id=video_id,
|
||
project_id=project_id,
|
||
generation_task_id="task-1",
|
||
name=f"video-{video_id}.mp4",
|
||
file_url=f"https://oss.example.com/{video_id}.mp4",
|
||
file_size=1024,
|
||
duration=5.0,
|
||
width=1280,
|
||
height=720,
|
||
fps=25.0,
|
||
status="completed",
|
||
)
|
||
|
||
def test_list_generated_videos_use_case(self):
|
||
"""ListGeneratedVideosUseCase 可正常列出视频。"""
|
||
from packages.application import ListGeneratedVideosUseCase
|
||
|
||
repo = MagicMock()
|
||
videos = [self._make_video(f"v{i}") for i in range(3)]
|
||
repo.list_by_project.return_value = videos
|
||
|
||
use_case = ListGeneratedVideosUseCase(repo)
|
||
result = use_case.execute("proj-1")
|
||
|
||
repo.list_by_project.assert_called_once_with("proj-1")
|
||
assert len(result) == 3
|
||
|
||
def test_get_generated_video_use_case(self):
|
||
"""GetGeneratedVideoUseCase 可正常获取单个视频。"""
|
||
from packages.application import GetGeneratedVideoUseCase
|
||
|
||
repo = MagicMock()
|
||
video = self._make_video("v1")
|
||
repo.get.return_value = video
|
||
|
||
use_case = GetGeneratedVideoUseCase(repo)
|
||
result = use_case.execute("v1")
|
||
|
||
repo.get.assert_called_once_with("v1")
|
||
assert result.id == "v1"
|
||
|
||
def test_list_by_task_use_case(self):
|
||
"""ListGeneratedVideosByTaskUseCase 可按任务列出视频。"""
|
||
from packages.application import ListGeneratedVideosByTaskUseCase
|
||
|
||
repo = MagicMock()
|
||
videos = [self._make_video(f"v{i}") for i in range(2)]
|
||
repo.list_by_generation_task.return_value = videos
|
||
|
||
use_case = ListGeneratedVideosByTaskUseCase(repo)
|
||
result = use_case.execute("task-1")
|
||
|
||
repo.list_by_generation_task.assert_called_once_with("task-1")
|
||
assert len(result) == 2
|