Files
xiaoxia-saas/tests/unit/test_mediakit_cover.py
T
xiaoxia 21e84c71c4
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m52s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m23s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m29s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m35s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m2s
CI/CD Pipeline / Unit Tests (push) Successful in 9m47s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 43s
CI/CD Pipeline / Integration Tests (push) Successful in 7m28s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m6s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
CI/CD Pipeline / Canary Release to Production (push) Failing after 810h18m24s
CI/CD Pipeline / Production Browser E2E (push) Failing after 810h25m14s
CI/CD Pipeline / Deploy Production (push) Failing after 810h25m16s
CI/CD Pipeline / Build Production Web Image (push) Failing after 810h25m16s
CI/CD Pipeline / Build Production API Image (push) Failing after 810h25m16s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 810h34m50s
CI/CD Pipeline / PR Build Web Image (push) Failing after 810h34m52s
CI/CD Pipeline / Frontend Lint (push) Failing after 810h35m3s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 810h35m47s
CI/CD Pipeline / CI Gate (push) Failing after 810h54m44s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 810h58m46s
CI/CD Pipeline / PR Build API Image (push) Failing after 811h8m24s
perf: render-time cover frame pre-extraction + FFmpeg fallback, remove MediaKit (#1360)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-13 20:51:02 +08:00

345 lines
13 KiB
Python
Executable File

"""MediaKit AI 封面生成单元测试.
测试 #1208: AI封面接入MediaKit视频截帧
"""
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
import pytest
from packages.shared.mediakit_client import MediaKitClient, get_mediakit_client
class TestMediaKitClient:
"""MediaKit 客户端测试."""
def test_is_available_with_key(self):
"""配置了 API Key 时可用."""
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
mock_settings.return_value.mediakit_api_key = "test-key"
mock_settings.return_value.mediakit_base_url = "https://test.com/api/v1"
mock_settings.return_value.mediakit_timeout = 60
client = MediaKitClient()
assert client.is_available is True
def test_is_available_without_key(self):
"""未配置 API Key 时不可用."""
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
mock_settings.return_value.mediakit_api_key = ""
mock_settings.return_value.mediakit_base_url = "https://test.com/api/v1"
mock_settings.return_value.mediakit_timeout = 60
client = MediaKitClient()
assert client.is_available is False
@patch("packages.shared.mediakit_client.httpx.post")
@patch("packages.shared.mediakit_client.httpx.get")
def test_extract_frames_success(self, mock_get, mock_post):
"""抽帧成功."""
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
mock_settings.return_value.mediakit_api_key = "test-key"
mock_settings.return_value.mediakit_base_url = "https://test.com/api/v1"
mock_settings.return_value.mediakit_timeout = 60
# Mock submit task
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {"task_id": "task-123"}
mock_post.return_value.raise_for_status = Mock()
# Mock poll task
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {
"status": "success",
"result": {
"snapshots": [
{"image_url": "https://example.com/frame1.jpg", "timestamp": 2.5},
{"image_url": "https://example.com/frame2.jpg", "timestamp": 5.0},
]
},
}
mock_get.return_value.raise_for_status = Mock()
client = MediaKitClient()
frames = client.extract_frames(
video_url="https://example.com/video.mp4",
strategy="SceneChange",
max_frames=5,
)
assert frames is not None
assert len(frames) == 2
assert frames[0]["image_url"] == "https://example.com/frame1.jpg"
assert frames[0]["timestamp"] == 2.5
@patch("packages.shared.mediakit_client.httpx.post")
def test_extract_frames_submit_failed(self, mock_post):
"""提交任务失败."""
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
mock_settings.return_value.mediakit_api_key = "test-key"
mock_settings.return_value.mediakit_base_url = "https://test.com/api/v1"
mock_settings.return_value.mediakit_timeout = 60
mock_post.side_effect = Exception("Network error")
client = MediaKitClient()
frames = client.extract_frames(
video_url="https://example.com/video.mp4",
strategy="SceneChange",
max_frames=5,
)
assert frames is None
@patch("packages.shared.mediakit_client.httpx.post")
@patch("packages.shared.mediakit_client.httpx.get")
def test_extract_frames_task_failed(self, mock_get, mock_post):
"""任务执行失败."""
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
mock_settings.return_value.mediakit_api_key = "test-key"
mock_settings.return_value.mediakit_base_url = "https://test.com/api/v1"
mock_settings.return_value.mediakit_timeout = 60
# Mock submit task
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {"task_id": "task-123"}
mock_post.return_value.raise_for_status = Mock()
# Mock poll task - failed
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {
"status": "failed",
"error": "Video processing failed",
}
mock_get.return_value.raise_for_status = Mock()
client = MediaKitClient()
frames = client.extract_frames(
video_url="https://example.com/video.mp4",
strategy="SceneChange",
max_frames=5,
)
assert frames is None
def test_extract_frames_not_available(self):
"""客户端不可用时返回 None."""
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
mock_settings.return_value.mediakit_api_key = ""
mock_settings.return_value.mediakit_base_url = "https://test.com/api/v1"
mock_settings.return_value.mediakit_timeout = 60
client = MediaKitClient()
frames = client.extract_frames(
video_url="https://example.com/video.mp4",
strategy="SceneChange",
max_frames=5,
)
assert frames is None
class TestAICoverService:
"""AI 封面服务测试(已迁移到 FFmpeg 本地抽帧)。"""
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_call_ai_cover_with_ffmpeg_success(self, mock_ffmpeg, mock_head):
"""FFmpeg 本地抽帧成功."""
import tempfile
mock_head.return_value.status_code = 200
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.write(b"\xff\xd8" + b"\x00" * 50)
tmp.close()
mock_ffmpeg.return_value = [{"local_path": tmp.name, "frame_time": 3.5}]
with patch("packages.shared.storage.get_shared_storage_service") as mock_storage_fn:
mock_storage = Mock()
mock_storage.upload_file = Mock()
mock_storage.get_url.return_value = "https://example.com/frame.jpg"
mock_storage_fn.return_value = mock_storage
from packages.shared.ai_service import _call_ai_cover_service
result = _call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
assert result["type"] == "ai_frame"
assert result["image_url"] == "https://example.com/frame.jpg"
assert result["frame_time"] == 3.5
assert result["confidence"] == 0.85
Path(tmp.name).unlink(missing_ok=True)
@patch("packages.shared.ai_service.http_requests.head")
def test_call_ai_cover_video_url_unreachable(self, mock_head):
"""视频 URL 不可访问时抛出 RuntimeError."""
mock_head.return_value.status_code = 404
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="预览视频URL不可访问"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="ai_frame",
primary_video_url="https://example.com/nonexistent.mp4",
)
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_call_ai_cover_url_double_slash_normalized(self, mock_ffmpeg, mock_head):
"""URL 路径中的双斜杠应被规范化."""
dirty_url = "https://oss.example.com/generated/projects//tasks/abc123/rendered.mp4"
clean_url = "https://oss.example.com/generated/projects/tasks/abc123/rendered.mp4"
mock_head.return_value.status_code = 200
mock_ffmpeg.return_value = []
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError):
_call_ai_cover_service(
plan_id="plan-1",
asset_ids=["a1"],
cover_type="ai_frame",
primary_video_url=dirty_url,
)
# HEAD 请求使用规范化后的 URL
mock_head.assert_called_once()
assert mock_head.call_args[0][0] == clean_url
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_call_ai_cover_ffmpeg_failure_raises(self, mock_ffmpeg, mock_head):
"""FFmpeg 抽帧失败时抛出 RuntimeError."""
mock_head.return_value.status_code = 200
mock_ffmpeg.side_effect = Exception("ffmpeg error")
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
def test_call_ai_cover_without_video_url_raises(self):
"""没有视频 URL 时抛出 RuntimeError."""
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="ai_frame",
primary_video_url=None,
)
def test_call_ai_cover_upload_type(self):
"""upload 类型直接返回."""
from packages.shared.ai_service import _call_ai_cover_service
result = _call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="upload",
primary_video_url="https://example.com/video.mp4",
)
assert result["type"] == "upload"
assert result["image_url"] == ""
def test_call_ai_cover_manual_type(self):
"""manual 类型返回指定时间点."""
from packages.shared.ai_service import _call_ai_cover_service
result = _call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="manual",
frame_time=5.0,
primary_video_url="https://example.com/video.mp4",
)
assert result["type"] == "manual"
assert result["frame_time"] == 5.0
@patch("packages.shared.ai_service.http_requests.head")
@patch("packages.shared.ai_service._extract_frames_with_ffmpeg")
def test_call_ai_cover_empty_frames_raises(self, mock_ffmpeg, mock_head):
"""FFmpeg 返回空帧列表时抛出 RuntimeError."""
mock_head.return_value.status_code = 200
mock_ffmpeg.return_value = []
from packages.shared.ai_service import _call_ai_cover_service
with pytest.raises(RuntimeError, match="无法从视频抽帧"):
_call_ai_cover_service(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
class TestGenerateCover:
"""run_generate_cover 测试."""
@patch("packages.shared.ai_service._call_ai_cover_service")
def test_run_generate_cover_passes_video_url(self, mock_cover_service):
"""正确传递 video_url 参数."""
mock_cover_service.return_value = {
"type": "ai_frame",
"image_url": "https://example.com/cover.jpg",
"frame_time": 2.0,
"confidence": 0.9,
}
from packages.shared.ai_service import run_generate_cover
result = run_generate_cover(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="ai_frame",
primary_video_url="https://example.com/video.mp4",
)
assert result["type"] == "ai_frame"
mock_cover_service.assert_called_once()
call_kwargs = mock_cover_service.call_args[1]
assert call_kwargs["primary_video_url"] == "https://example.com/video.mp4"
@patch("packages.shared.ai_service._call_ai_cover_service")
def test_run_generate_cover_without_video_url(self, mock_cover_service):
"""不传 video_url 时默认为 None."""
mock_cover_service.return_value = {
"type": "ai_frame",
"image_url": "https://example.com/cover.jpg",
"frame_time": 2.0,
"confidence": 0.9,
}
from packages.shared.ai_service import run_generate_cover
result = run_generate_cover(
plan_id="plan-123",
asset_ids=["asset-1"],
cover_type="ai_frame",
)
assert result["type"] == "ai_frame"
call_kwargs = mock_cover_service.call_args[1]
assert call_kwargs["primary_video_url"] is None