fca943428b
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m43s
AI Code Review / AI Code Review (pull_request) Successful in 6m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 12m6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 10s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 36s
问题:POST /api/v1/scripts/extract-from-douyin 在抖音短链无效/网络异常/ASR 兜底异常等 场景下冒泡未捕获 Exception,被全局 general_exception_handler 吞成 500 INTERNAL_ERROR, 前端无法区分业务错误。 修复(apps/api/app/api/routes/scripts_ai.py): 1. yt-dlp 导入失败(缺依赖)→ 503(提示功能不可用) 2. yt_dlp.utils.DownloadError 单独捕获: - 含 404/not found/unable to download/unsupported url → 400「无法解析该抖音链接」 - 其它网络/上游异常 → 502「视频下载失败」 3. 下载 info 成功但文件未落地(isfile False / size 0)→ 502「未获取到有效文件」 4. duration 非数字(TypeError/ValueError)→ 安全降级为 0 5. ASR 转写兜底 catch Exception → 502「语音识别失败」(原只 catch 两个自定义异常) 6. 最外层兜底 catch Exception → 500 但带具体 detail(不再是通用 INTERNAL_ERROR) 7. text/duration 提前初始化,避免异常路径 UnboundLocalError 测试: - 新增 tests/unit/test_extract_from_douyin_errors.py,覆盖 8 个失败场景 - 更新 tests/unit/test_scripts_ai.py 三个成功/ASR 路径测试,mock os.path.isfile/getsize - 全量单测 15531 passed
449 lines
16 KiB
Python
449 lines
16 KiB
Python
"""Scripts AI 能力单元测试 — Issue #1893.
|
|
|
|
测试覆盖:
|
|
- extract_from_douyin: 成功/非法URL/下载失败/ASR未配置/ASR失败
|
|
- ai_rewrite: 成功/空内容/LLM失败
|
|
- ai_generate_titles: 成功/count截断/空内容
|
|
|
|
所有外部调用(yt_dlp、ASR、LLM)均通过 unittest.mock.patch 隔离。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pydantic
|
|
import pytest
|
|
|
|
import packages.middleware.points_gate as _pg_module
|
|
|
|
sys.path.insert(0, "apps/api")
|
|
|
|
|
|
def _make_auth_user(user_id: str = "u1"):
|
|
"""构造 mock AuthenticatedUser."""
|
|
user = MagicMock()
|
|
user.id = user_id
|
|
auth = MagicMock()
|
|
auth.user = user
|
|
return auth
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _disable_points_gate(monkeypatch):
|
|
"""默认关闭积分闸门,避免影响既有用例。"""
|
|
monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: False)
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
return MagicMock()
|
|
|
|
|
|
def _mock_youtube_dl(
|
|
extract_info_return=None,
|
|
extract_info_side_effect=None,
|
|
prepare_filename_return="/tmp/douyin_extract_abc/abc123.mp4",
|
|
):
|
|
"""构造 yt_dlp.YoutubeDL 的 mock.
|
|
|
|
路由中用法: ydl = yt_dlp.YoutubeDL(opts); info = ydl.extract_info(...)
|
|
所以 mock_ydl_cls.return_value 就是 ydl 实例.
|
|
"""
|
|
mock_ydl_instance = MagicMock()
|
|
if extract_info_side_effect is not None:
|
|
mock_ydl_instance.extract_info.side_effect = extract_info_side_effect
|
|
else:
|
|
mock_ydl_instance.extract_info.return_value = extract_info_return or {
|
|
"id": "abc123",
|
|
"duration": 120.5,
|
|
}
|
|
mock_ydl_instance.prepare_filename.return_value = prepare_filename_return
|
|
return mock_ydl_instance
|
|
|
|
|
|
# ── extract_from_douyin ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestExtractFromDouyin:
|
|
"""POST /extract-from-douyin 测试."""
|
|
|
|
@patch("app.api.routes.scripts_ai.transcribe_to_text")
|
|
@patch("tempfile.TemporaryDirectory")
|
|
@patch("yt_dlp.YoutubeDL")
|
|
@patch("app.api.routes.scripts_ai.os.path.getsize", return_value=1024)
|
|
@patch("app.api.routes.scripts_ai.os.path.isfile", return_value=True)
|
|
def test_extract_from_douyin_success(
|
|
self,
|
|
mock_isfile,
|
|
mock_getsize,
|
|
mock_ydl_cls,
|
|
mock_tempdir,
|
|
mock_transcribe,
|
|
):
|
|
"""正常流程:下载视频 + ASR 转写成功."""
|
|
from app.api.routes.scripts_ai import extract_from_douyin
|
|
from app.schemas.scripts_ai import ExtractFromDouyinRequest
|
|
|
|
mock_ydl_cls.return_value = _mock_youtube_dl(
|
|
extract_info_return={"id": "abc123", "duration": 120.5},
|
|
)
|
|
|
|
mock_td = MagicMock()
|
|
mock_td.__enter__ = MagicMock(return_value="/tmp/douyin_extract_abc")
|
|
mock_td.__exit__ = MagicMock(return_value=False)
|
|
mock_tempdir.return_value = mock_td
|
|
|
|
mock_transcribe.return_value = "这是一段测试文案内容"
|
|
|
|
req = ExtractFromDouyinRequest(url="https://v.douyin.com/xxxxx/")
|
|
auth = _make_auth_user()
|
|
result = extract_from_douyin(request=req, current_user=auth)
|
|
|
|
assert result.text == "这是一段测试文案内容"
|
|
assert result.duration_seconds == 120.5
|
|
assert result.source_url == "https://v.douyin.com/xxxxx/"
|
|
mock_transcribe.assert_called_once()
|
|
mock_tempdir.assert_called_once()
|
|
mock_td.__exit__.assert_called_once()
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_url",
|
|
[
|
|
"",
|
|
"not-a-url",
|
|
"https://www.youtube.com/watch?v=abc",
|
|
"https://www.bilibili.com/video/BV123",
|
|
"https://douyin.com/something",
|
|
"ftp://v.douyin.com/xxx/",
|
|
],
|
|
)
|
|
def test_extract_from_douyin_invalid_url(self, bad_url):
|
|
"""非法 URL 返回 400."""
|
|
from app.api.routes.scripts_ai import extract_from_douyin
|
|
from app.schemas.scripts_ai import ExtractFromDouyinRequest
|
|
from fastapi import HTTPException
|
|
|
|
req = ExtractFromDouyinRequest(url=bad_url)
|
|
auth = _make_auth_user()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
extract_from_douyin(request=req, current_user=auth)
|
|
assert exc_info.value.status_code == 400
|
|
|
|
@patch("tempfile.TemporaryDirectory")
|
|
@patch("yt_dlp.YoutubeDL")
|
|
def test_extract_from_douyin_download_failure(self, mock_ydl_cls, mock_tempdir):
|
|
"""下载失败返回 502."""
|
|
from app.api.routes.scripts_ai import extract_from_douyin
|
|
from app.schemas.scripts_ai import ExtractFromDouyinRequest
|
|
from fastapi import HTTPException
|
|
|
|
mock_ydl_cls.return_value = _mock_youtube_dl(
|
|
extract_info_side_effect=Exception("Video unavailable"),
|
|
)
|
|
|
|
mock_td = MagicMock()
|
|
mock_td.__enter__ = MagicMock(return_value="/tmp/douyin_extract_abc")
|
|
mock_td.__exit__ = MagicMock(return_value=False)
|
|
mock_tempdir.return_value = mock_td
|
|
|
|
req = ExtractFromDouyinRequest(url="https://v.douyin.com/xxxxx/")
|
|
auth = _make_auth_user()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
extract_from_douyin(request=req, current_user=auth)
|
|
assert exc_info.value.status_code == 502
|
|
|
|
@patch("app.api.routes.scripts_ai.transcribe_to_text")
|
|
@patch("tempfile.TemporaryDirectory")
|
|
@patch("yt_dlp.YoutubeDL")
|
|
@patch("app.api.routes.scripts_ai.os.path.getsize", return_value=1024)
|
|
@patch("app.api.routes.scripts_ai.os.path.isfile", return_value=True)
|
|
def test_extract_from_douyin_asr_not_configured(
|
|
self,
|
|
mock_isfile,
|
|
mock_getsize,
|
|
mock_ydl_cls,
|
|
mock_tempdir,
|
|
mock_transcribe,
|
|
):
|
|
"""ASR 未配置返回 503."""
|
|
from app.api.routes.scripts_ai import extract_from_douyin
|
|
from app.schemas.scripts_ai import ExtractFromDouyinRequest
|
|
from app.services.script_asr_service import ASRNotConfiguredError
|
|
from fastapi import HTTPException
|
|
|
|
mock_ydl_cls.return_value = _mock_youtube_dl(
|
|
extract_info_return={"id": "abc123", "duration": 60},
|
|
)
|
|
|
|
mock_td = MagicMock()
|
|
mock_td.__enter__ = MagicMock(return_value="/tmp/douyin_extract_abc")
|
|
mock_td.__exit__ = MagicMock(return_value=False)
|
|
mock_tempdir.return_value = mock_td
|
|
|
|
mock_transcribe.side_effect = ASRNotConfiguredError("ASR 服务未配置")
|
|
|
|
req = ExtractFromDouyinRequest(url="https://v.douyin.com/xxxxx/")
|
|
auth = _make_auth_user()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
extract_from_douyin(request=req, current_user=auth)
|
|
assert exc_info.value.status_code == 503
|
|
|
|
@patch("app.api.routes.scripts_ai.transcribe_to_text")
|
|
@patch("tempfile.TemporaryDirectory")
|
|
@patch("yt_dlp.YoutubeDL")
|
|
@patch("app.api.routes.scripts_ai.os.path.getsize", return_value=1024)
|
|
@patch("app.api.routes.scripts_ai.os.path.isfile", return_value=True)
|
|
def test_extract_from_douyin_asr_failure(
|
|
self,
|
|
mock_isfile,
|
|
mock_getsize,
|
|
mock_ydl_cls,
|
|
mock_tempdir,
|
|
mock_transcribe,
|
|
):
|
|
"""ASR 调用失败返回 502."""
|
|
from app.api.routes.scripts_ai import extract_from_douyin
|
|
from app.schemas.scripts_ai import ExtractFromDouyinRequest
|
|
from app.services.script_asr_service import ASRTranscriptionError
|
|
from fastapi import HTTPException
|
|
|
|
mock_ydl_cls.return_value = _mock_youtube_dl(
|
|
extract_info_return={"id": "abc123", "duration": 60},
|
|
)
|
|
|
|
mock_td = MagicMock()
|
|
mock_td.__enter__ = MagicMock(return_value="/tmp/douyin_extract_abc")
|
|
mock_td.__exit__ = MagicMock(return_value=False)
|
|
mock_tempdir.return_value = mock_td
|
|
|
|
mock_transcribe.side_effect = ASRTranscriptionError("语音识别失败: timeout")
|
|
|
|
req = ExtractFromDouyinRequest(url="https://v.douyin.com/xxxxx/")
|
|
auth = _make_auth_user()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
extract_from_douyin(request=req, current_user=auth)
|
|
assert exc_info.value.status_code == 502
|
|
|
|
|
|
# ── ai_rewrite ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestAiRewrite:
|
|
"""POST /ai-rewrite 测试."""
|
|
|
|
@patch("app.api.routes.scripts_ai.get_doubao_client")
|
|
def test_ai_rewrite_success(self, mock_get_client):
|
|
"""正常改写成功."""
|
|
from app.api.routes.scripts_ai import ai_rewrite
|
|
from app.schemas.scripts_ai import AiRewriteRequest
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.is_available = True
|
|
mock_client.chat_completion.return_value = "改写后的文案内容,口语化风格"
|
|
mock_get_client.return_value = mock_client
|
|
|
|
req = AiRewriteRequest(content="原始文案内容", style="口语化")
|
|
auth = _make_auth_user()
|
|
result = ai_rewrite(request=req, current_user=auth)
|
|
|
|
assert result.original == "原始文案内容"
|
|
assert result.rewritten == "改写后的文案内容,口语化风格"
|
|
assert result.style == "口语化"
|
|
mock_client.chat_completion.assert_called_once()
|
|
|
|
def test_ai_rewrite_empty_content(self):
|
|
"""空内容返回 400."""
|
|
from app.api.routes.scripts_ai import ai_rewrite
|
|
from app.schemas.scripts_ai import AiRewriteRequest
|
|
from fastapi import HTTPException
|
|
|
|
req = AiRewriteRequest(content=" ", style="口语化")
|
|
auth = _make_auth_user()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
ai_rewrite(request=req, current_user=auth)
|
|
assert exc_info.value.status_code == 400
|
|
|
|
@patch("app.api.routes.scripts_ai.get_doubao_client")
|
|
def test_ai_rewrite_llm_failure(self, mock_get_client):
|
|
"""LLM 调用失败返回 502."""
|
|
from app.api.routes.scripts_ai import ai_rewrite
|
|
from app.schemas.scripts_ai import AiRewriteRequest
|
|
from fastapi import HTTPException
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.is_available = True
|
|
mock_client.chat_completion.side_effect = Exception("API timeout")
|
|
mock_get_client.return_value = mock_client
|
|
|
|
req = AiRewriteRequest(content="测试内容", style="口语化")
|
|
auth = _make_auth_user()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
ai_rewrite(request=req, current_user=auth)
|
|
assert exc_info.value.status_code == 502
|
|
|
|
@patch("app.api.routes.scripts_ai.get_doubao_client")
|
|
def test_ai_rewrite_client_unavailable(self, mock_get_client):
|
|
"""客户端不可用返回 502."""
|
|
from app.api.routes.scripts_ai import ai_rewrite
|
|
from app.schemas.scripts_ai import AiRewriteRequest
|
|
from fastapi import HTTPException
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.is_available = False
|
|
mock_get_client.return_value = mock_client
|
|
|
|
req = AiRewriteRequest(content="测试内容", style="口语化")
|
|
auth = _make_auth_user()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
ai_rewrite(request=req, current_user=auth)
|
|
assert exc_info.value.status_code == 502
|
|
|
|
|
|
# ── ai_generate_titles ───────────────────────────────────────────────────────
|
|
|
|
|
|
class TestAiGenerateTitles:
|
|
"""POST /ai-generate-titles 测试."""
|
|
|
|
@patch("app.services.ai_service.get_doubao_client")
|
|
def test_generate_titles_success(self, mock_get_client):
|
|
"""正常生成标题."""
|
|
from app.api.routes.scripts_ai import ai_generate_titles
|
|
from app.schemas.scripts_ai import AiGenerateTitlesRequest
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.is_available = False # 走 fallback 路径
|
|
mock_get_client.return_value = mock_client
|
|
|
|
req = AiGenerateTitlesRequest(content="这是一段关于美食的文案", count=3)
|
|
auth = _make_auth_user()
|
|
result = ai_generate_titles(request=req, current_user=auth)
|
|
|
|
assert len(result.titles) == 3
|
|
assert all(isinstance(t, str) for t in result.titles)
|
|
|
|
@patch("app.services.ai_service.get_doubao_client")
|
|
def test_generate_titles_count_clamp(self, mock_get_client):
|
|
"""count 超出范围时 Pydantic 校验拦截."""
|
|
from app.schemas.scripts_ai import AiGenerateTitlesRequest
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.is_available = False
|
|
mock_get_client.return_value = mock_client
|
|
|
|
auth = _make_auth_user()
|
|
|
|
# count=10 被 Pydantic le=5 校验拦截 → ValidationError
|
|
with pytest.raises(pydantic.ValidationError):
|
|
AiGenerateTitlesRequest(content="测试", count=10)
|
|
|
|
# count=0 被 Pydantic ge=1 校验拦截 → ValidationError
|
|
with pytest.raises(pydantic.ValidationError):
|
|
AiGenerateTitlesRequest(content="测试", count=0)
|
|
|
|
@patch("app.services.ai_service.get_doubao_client")
|
|
def test_generate_titles_count_valid_range(self, mock_get_client):
|
|
"""count=1 和 count=5 正常工作."""
|
|
from app.api.routes.scripts_ai import ai_generate_titles
|
|
from app.schemas.scripts_ai import AiGenerateTitlesRequest
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.is_available = False
|
|
mock_get_client.return_value = mock_client
|
|
|
|
auth = _make_auth_user()
|
|
|
|
# count=5
|
|
req = AiGenerateTitlesRequest(content="测试内容", count=5)
|
|
result = ai_generate_titles(request=req, current_user=auth)
|
|
assert len(result.titles) <= 5
|
|
|
|
# count=1
|
|
req = AiGenerateTitlesRequest(content="测试内容", count=1)
|
|
result = ai_generate_titles(request=req, current_user=auth)
|
|
assert len(result.titles) >= 1
|
|
|
|
def test_generate_titles_empty_content(self):
|
|
"""空内容返回 400."""
|
|
from app.api.routes.scripts_ai import ai_generate_titles
|
|
from app.schemas.scripts_ai import AiGenerateTitlesRequest
|
|
from fastapi import HTTPException
|
|
|
|
req = AiGenerateTitlesRequest(content="", count=3)
|
|
auth = _make_auth_user()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
ai_generate_titles(request=req, current_user=auth)
|
|
assert exc_info.value.status_code == 400
|
|
|
|
@patch("app.services.ai_service.get_doubao_client")
|
|
def test_generate_titles_default_count(self, mock_get_client):
|
|
"""不传 count 时默认 3."""
|
|
from app.api.routes.scripts_ai import ai_generate_titles
|
|
from app.schemas.scripts_ai import AiGenerateTitlesRequest
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.is_available = False
|
|
mock_get_client.return_value = mock_client
|
|
|
|
req = AiGenerateTitlesRequest(content="测试文案内容")
|
|
auth = _make_auth_user()
|
|
result = ai_generate_titles(request=req, current_user=auth)
|
|
|
|
assert len(result.titles) == 3
|
|
|
|
|
|
# ── URL 校验辅助函数 ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestValidateDouyinUrl:
|
|
"""URL 校验逻辑单元测试."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"valid_url",
|
|
[
|
|
"https://v.douyin.com/abc123/",
|
|
"http://v.douyin.com/abc123/",
|
|
"v.douyin.com/abc123/",
|
|
"https://www.douyin.com/video/1234567890",
|
|
"http://www.douyin.com/video/1234567890",
|
|
"www.douyin.com/video/1234567890",
|
|
],
|
|
)
|
|
def test_valid_urls(self, valid_url):
|
|
"""合法 URL 不抛异常."""
|
|
from app.api.routes.scripts_ai import _validate_douyin_url
|
|
|
|
_validate_douyin_url(valid_url)
|
|
|
|
@pytest.mark.parametrize(
|
|
"invalid_url",
|
|
[
|
|
"",
|
|
" ",
|
|
"https://www.youtube.com/watch?v=abc",
|
|
"https://www.bilibili.com/video/BV123",
|
|
"https://douyin.com/something",
|
|
"ftp://v.douyin.com/xxx/",
|
|
"not-a-url",
|
|
],
|
|
)
|
|
def test_invalid_urls(self, invalid_url):
|
|
"""非法 URL 抛 400."""
|
|
from app.api.routes.scripts_ai import _validate_douyin_url
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
_validate_douyin_url(invalid_url)
|
|
assert exc_info.value.status_code == 400
|