Files
xiaoxia-saas/tests/unit/test_extract_from_douyin_errors.py
T
xiaoxia-agent 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
fix(scripts): extract-from-douyin 异常路径全部返回业务码,杜绝 500 INTERNAL_ERROR
问题: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
2026-09-16 13:16:45 +08:00

197 lines
8.8 KiB
Python
Raw 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.
"""验证 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