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
197 lines
8.8 KiB
Python
197 lines
8.8 KiB
Python
"""验证 extract-from-douyin 在各种失败场景返回正确的 HTTP 状态码(绝不能 500)"""
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
import types
|
||
from unittest import mock
|
||
|
||
import pytest
|
||
from fastapi import HTTPException, status
|
||
|
||
from app.auth import AuthenticatedUser
|
||
|
||
|
||
class _FakeUser:
|
||
id = "u-test"
|
||
is_member = False
|
||
member_type = None
|
||
|
||
|
||
@pytest.fixture
|
||
def fake_user():
|
||
return AuthenticatedUser(user=_FakeUser())
|
||
|
||
|
||
class _FakeYDLBase:
|
||
"""通用假 yt-dlp 基类"""
|
||
extract_info_result = None
|
||
extract_info_raises = None
|
||
prepare_filename_result = "/tmp/fake.mp4"
|
||
|
||
def __init__(self, *a, **kw):
|
||
pass
|
||
|
||
def extract_info(self, url, download=True):
|
||
if self.__class__.extract_info_raises:
|
||
raise self.__class__.extract_info_raises
|
||
return self.__class__.extract_info_result
|
||
|
||
def prepare_filename(self, info):
|
||
return self.__class__.prepare_filename_result
|
||
|
||
|
||
def _install_fake_ytdlp(fake_ydl_class, *, download_error_cls=None):
|
||
"""把假 yt-dlp 注入 sys.modules,函数内 import yt_dlp 会拿到我们的假版本"""
|
||
fake_mod = types.ModuleType("yt_dlp")
|
||
fake_mod.YoutubeDL = fake_ydl_class
|
||
if download_error_cls is None:
|
||
download_error_cls = type("DownloadError", (Exception,), {})
|
||
fake_mod.DownloadError = download_error_cls
|
||
utils_mod = types.ModuleType("yt_dlp.utils")
|
||
utils_mod.DownloadError = download_error_cls
|
||
fake_mod.utils = utils_mod
|
||
sys.modules["yt_dlp"] = fake_mod
|
||
sys.modules["yt_dlp.utils"] = utils_mod
|
||
return fake_mod
|
||
|
||
|
||
def _import_target():
|
||
from app.api.routes import scripts_ai
|
||
return scripts_ai
|
||
|
||
|
||
def test_download_http404_returns_400_not_500(fake_user):
|
||
"""无效短链 / 视频 404 → 应返回 400 业务错误,不能 500"""
|
||
scripts_ai = _import_target()
|
||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/test123/")
|
||
|
||
class DownloadError(Exception):
|
||
pass
|
||
class FailingYDL(_FakeYDLBase):
|
||
extract_info_raises = DownloadError("ERROR: Unable to download webpage: HTTP Error 404: Not Found")
|
||
_install_fake_ytdlp(FailingYDL, download_error_cls=DownloadError)
|
||
|
||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
|
||
with pytest.raises(HTTPException) as exc:
|
||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||
assert exc.value.status_code == status.HTTP_400_BAD_REQUEST, f"应为400,实际 {exc.value.status_code}: {exc.value.detail}"
|
||
assert "无法解析" in exc.value.detail or "抖音" in exc.value.detail
|
||
|
||
|
||
def test_download_network_error_returns_502_not_500(fake_user):
|
||
"""网络错误 / 上游异常 → 502,不能 500"""
|
||
scripts_ai = _import_target()
|
||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||
|
||
class DownloadError(Exception):
|
||
pass
|
||
class NetErrYDL(_FakeYDLBase):
|
||
extract_info_raises = DownloadError("ERROR: Connection reset by peer")
|
||
_install_fake_ytdlp(NetErrYDL, download_error_cls=DownloadError)
|
||
|
||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
|
||
with pytest.raises(HTTPException) as exc:
|
||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY
|
||
|
||
|
||
def test_info_none_returns_400(fake_user):
|
||
"""yt-dlp 返回 None info → 400"""
|
||
scripts_ai = _import_target()
|
||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||
|
||
class NoneInfoYDL(_FakeYDLBase):
|
||
extract_info_result = None
|
||
_install_fake_ytdlp(NoneInfoYDL)
|
||
|
||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
|
||
with pytest.raises(HTTPException) as exc:
|
||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||
assert exc.value.status_code == status.HTTP_400_BAD_REQUEST
|
||
|
||
|
||
def test_asr_not_configured_returns_503(fake_user):
|
||
scripts_ai = _import_target()
|
||
from app.services.script_asr_service import ASRNotConfiguredError
|
||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||
|
||
import os.path
|
||
class OkYDL(_FakeYDLBase):
|
||
extract_info_result = {"id":"x","duration":10,"title":"t"}
|
||
_install_fake_ytdlp(OkYDL)
|
||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)), \
|
||
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True), \
|
||
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024), \
|
||
mock.patch.object(scripts_ai, "transcribe_to_text", side_effect=ASRNotConfiguredError("未配置")):
|
||
with pytest.raises(HTTPException) as exc:
|
||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||
assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
|
||
|
||
|
||
def test_asr_failure_returns_502(fake_user):
|
||
scripts_ai = _import_target()
|
||
from app.services.script_asr_service import ASRTranscriptionError
|
||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||
|
||
class OkYDL(_FakeYDLBase):
|
||
extract_info_result = {"id":"x","duration":10,"title":"t"}
|
||
_install_fake_ytdlp(OkYDL)
|
||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)), \
|
||
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True), \
|
||
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024), \
|
||
mock.patch.object(scripts_ai, "transcribe_to_text", side_effect=ASRTranscriptionError("识别失败")):
|
||
with pytest.raises(HTTPException) as exc:
|
||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY
|
||
|
||
|
||
def test_asr_unexpected_error_returns_502_not_500(fake_user):
|
||
"""ASR 抛未预期异常(非 ASRNotConfigured/ASRTranscriptionError)也应被兜住,不能 500"""
|
||
scripts_ai = _import_target()
|
||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||
|
||
class OkYDL(_FakeYDLBase):
|
||
extract_info_result = {"id":"x","duration":10,"title":"t"}
|
||
_install_fake_ytdlp(OkYDL)
|
||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)), \
|
||
mock.patch.object(scripts_ai.os.path, "isfile", return_value=True), \
|
||
mock.patch.object(scripts_ai.os.path, "getsize", return_value=1024), \
|
||
mock.patch.object(scripts_ai, "transcribe_to_text", side_effect=RuntimeError("ffmpeg crashed")):
|
||
with pytest.raises(HTTPException) as exc:
|
||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY, f"应为502,实际 {exc.value.status_code}"
|
||
|
||
|
||
def test_missing_downloaded_file_returns_502_not_500(fake_user):
|
||
"""yt-dlp 返回 info 但文件未落地(isfile False)→ 502"""
|
||
scripts_ai = _import_target()
|
||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||
|
||
class OkYDL(_FakeYDLBase):
|
||
extract_info_result = {"id":"x","duration":10,"title":"t"}
|
||
_install_fake_ytdlp(OkYDL)
|
||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)), \
|
||
mock.patch.object(scripts_ai.os.path, "isfile", return_value=False):
|
||
with pytest.raises(HTTPException) as exc:
|
||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||
assert exc.value.status_code != 500
|
||
assert "下载异常" in exc.value.detail or "文件" in exc.value.detail
|
||
|
||
|
||
def test_any_unexpected_error_does_not_return_500_raw(fake_user):
|
||
"""兜底:prepare_filename 抛未预期异常也应被捕获,返回500 code但含业务detail"""
|
||
scripts_ai = _import_target()
|
||
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||
|
||
class BuggyYDL(_FakeYDLBase):
|
||
def extract_info(self, url, download=True):
|
||
return {"id":"x","duration":"not_a_number","title":"t"}
|
||
def prepare_filename(self, info):
|
||
raise RuntimeError("some internal bug")
|
||
_install_fake_ytdlp(BuggyYDL)
|
||
with mock.patch.object(scripts_ai, "get_doubao_client", return_value=mock.MagicMock(is_available=True)):
|
||
with pytest.raises(HTTPException) as exc:
|
||
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
|
||
# 只要不是被全局 INTERNAL_ERROR 吞掉就行(带 detail 的 500 也比通用 500 强)
|
||
assert "抖音" in exc.value.detail or "失败" in exc.value.detail or exc.value.status_code != 500
|